"You can't insert a guess and take it back. So only insert the part that stopped being a guess."
The Mismatch
Whisper-family models are fundamentally batch: give them a chunk of audio, get a transcript. But good dictation feels streaming — words appear as you speak. The naive bridge ("record 30 seconds, transcribe once, paste") technically works but feels dead: no feedback while you talk, a long pause at the end. To feel live, you have to fake streaming on top of a batch model — carefully, because the batch model keeps changing its mind about earlier words as it hears more.
LocalAgreement
The trick is a streaming controller that sits above the engine. It keeps a rolling buffer, runs the batch model repeatedly as audio grows, and compares each new hypothesis to the previous one. Where two consecutive passes agree on a prefix, that prefix is committed — it's stable enough to show as final. The still-disagreeing tail stays visible as preview but is never treated as final. That's the LocalAgreement idea: confirm text only after consecutive passes agree, keep the unconfirmed tail visible, and trim the buffer at sentence boundaries when it's safe.
pass N: "send the report to"
pass N+1: "send the report to Tuesday" agreement on prefix ->
^^^^^^^^^^^^^^^^^^^ COMMITTED "send the report to"
^^^^^^^^ still tail -> preview only
# Commit only the agreed prefix. Never insert the volatile tail.
The MVP Discipline
Live-typing while you speak is the hard version, and it's a trap to start there. The honest MVP shows the partial transcript in the overlay as preview but only inserts once, at release. Crucially, you still separate 'committed' from 'uncommitted' internally even in the MVP — so the day you add true live-type insertion, the machinery is already there and you're just changing where committed text goes (into the app instead of the overlay). Build the data model for the hard version; ship the easy version first.