Field notes

Two Apps, One Folder: How CallScribe and Prompter Talk Without an API

October 30, 2026 · 14 min read · Nasim Bayati

Most integration stories start with an API. Mine starts with a folder.

CallScribe is a Windows tool I built to turn live speech into text on my own machine, with no audio leaving the computer, and to prepare suggested answers from my own notes while I practice technical conversations out loud. Prompter is a second, separate application: a teleprompter-style display that shows text in sections I can jump between with hotkeys.

The interesting problem was never the transcription. Whisper is very good, and running it locally is a solved problem. The interesting problem was this: how do two independent programs cooperate, one producing answers, the other displaying them, without becoming one program?

I did not want a shared library, a local HTTP API, or a plugin system. I wanted either app to be replaceable, either app to crash without taking the other down, and the whole exchange to be inspectable with nothing more than a file explorer. So the integration is a watched folder and a naming rule. This post is about that contract, the privacy decisions inside it, and what measuring the system honestly looks like.

A note on scope: I maintain more than one build of CallScribe. Everything in this post describes the general build: local transcription, prepared answers from my own notes, an optional AI layer, and the Prompter bridge. That is the build I run, test, and am describing here.

At a glance

QuestionAnswer
What is CallScribe?A Windows application that captures the other side of a conversation, transcribes it locally with Whisper (faster-whisper) on my own GPU, and prepares suggested answers, first from my own prepared notes, optionally from an AI model I configure
What is Prompter?A separate display application that presents text in sections, navigable by global hotkeys, search, and an edit mode
How do they communicate?A watched inbox folder. CallScribe writes one JSON file per answer; Prompter turns each file into a new section
What is the file contract?{"title": ..., "text": ...}, written to a .tmp file and atomically renamed to .json so the reader never sees a partial write
Where does the audio go?Nowhere. Audio is captured, transcribed, and discarded on the same machine; it is never written to disk and never uploaded
When does text leave the machine?Only when I switch the AI layer on, and only to the endpoint I configured. It is off by default
What happens if Prompter is not running?Uncollected inbox files are swept after five minutes instead of accumulating on disk
How is accuracy checked?The project ships its own evaluation command that transcribes a known clip against a reference transcript
What did I verify?Local transcription, the opt-in boundary, the bridge contract, the stale-file sweep, the provider design, and a 61-check offline smoke suite (59 passing in a clean sandbox; the 2 exceptions were a missing HTTP library in that sandbox, not application failures)

The problem I was actually solving

I practice technical conversations the way I study anything: out loud, against realistic questions, with my own notes nearby. The tool I wanted had three properties that cloud transcription services do not offer by default.

First, the audio had to stay home. A practice session is still a conversation. I did not want recordings, or even audio snippets, uploaded to a transcription API as a side effect of getting text on my screen. Local speech-to-text had to be the default path, not a settings page promise.

Second, help had to be optional and visibly bounded. Prepared answers from my own notes are instant and offline; they should always be available. An AI model’s suggestions are a different category of thing, because text leaves the machine, so that layer needed to be off by default, behind an explicit switch, pointed at an endpoint I chose.

Third, the display had to be its own program. A teleprompter that shows one section at a time, jumps by hotkey, and survives independently is useful with or without transcription behind it. Gluing it into the transcription app would have made both worse.

Those three properties produced two applications and one integration question.

CallScribe: local transcription, answers in layers

CallScribe captures the other side of the conversation through WASAPI loopback, meaning it hears what my speakers play, not my microphone, and feeds that stream to faster-whisper running on my GPU.

The capture pipeline taught me more than the model did. A few design points that earned their place:

An adaptive voice gate. A fixed energy threshold for “someone is talking” fails the moment a call is quieter or louder than the last one. The transcriber tracks the stream’s own noise floor and adapts its gate to it. There is also a --calibrate mode that simply measures the capture stream, silence levels with nobody talking, then speech levels, and tells me the numbers, instead of me guessing a threshold and discovering it was wrong mid-conversation.

Partial captions are not final transcripts. While someone is speaking, the app shows fast, low-beam partial captions so the screen feels live. But a partial is never what gets committed as the transcript. The committed line always comes from a full decode of the finished utterance (temperature pinned to 0.0, a proper beam), because a sentence you act on should not be the draft the model was still revising.

Bounded queues, on purpose. If transcription falls behind, captured audio used to accumulate, and delayed help is worse than slightly incomplete help. The audio queues are now bounded to a few seconds and drop the oldest chunks. The system stays live instead of becoming a recording of the past.

A doctor before a demo. CallScribe.bat --doctor runs sixteen checks over the whole setup (Python environment, speech model, audio device, voice gate, provider, credential, inbox, privacy settings) and reports failures with the command that fixes them. On my machine today it reads: 16 checks, 0 failed, 1 warning, the warning being that my prepared-answers file still has unfilled templates in it, which is true and worth knowing before a call rather than during one. A checker that only ever says “ok” is decoration, so the warning stays in the screenshot. Tools that only fail during the real event are not finished tools.

The doctor’s report on my machine: sixteen checks over platform, model, audio, voice gate, provider, credential, inbox, and privacy settings. 0 failed, 1 warning.

Answers, in the order I trust them

When a question is detected, the answer comes from the first layer that can honestly produce one:

  1. My prepared notes. My background file (context.md) is organized under headings. When a spoken question closely matches a heading, the text under it appears instantly: offline, no model, in my own words, because I wrote it.
  2. An AI model I configure. If the notes do not match and I have switched the AI layer on, the question goes to the configured endpoint (the current build uses an OpenAI-compatible streaming provider), and the answer streams into the pane. Off by default; on only by my action.

Two details from a recent fixes pass belong here, because they are the kind of bugs demos never show. A slow answer to an old question used to be able to finish late and overwrite the answer to the new one. The providers now carry request IDs, and stale answers are suppressed. And the model used to receive a synthetic prompt (history, titles, instructions) as if it were the question, which then leaked into the UI and into Prompter’s section titles. The app now keeps the clean question for display and history, and sends the rich prompt only to the model. Same system, two very different behaviors, and the difference is invisible until you trace what each consumer actually receives.

CallScribe during the synthetic demo: the two scripted questions in the live transcript and the AI layer’s suggested answer below. Fictional questions only.

Prompter: the display is a separate program

Prompter shows text the way a speaker actually needs it during a conversation: in sections. A sidebar lists them; number hotkeys and Ctrl+Alt navigation jump between them instantly; a quick search filters by text; an edit mode manages the content; font size and opacity adjust live. It remembers its window, its settings, and its sections between launches, and it knows nothing about transcription, Whisper, or AI. Its inputs are a person typing, and one folder.

That ignorance is the design. Prompter is a good display for rehearsed material whether the text was typed by hand, written by another tool I control, or delivered by the bridge described next.

Prompter displaying a delivered section, with the section list in the sidebar. Both sections arrived from CallScribe through the inbox during the synthetic demo.

The contract: one folder, one file, one rename

Here is the entire integration protocol:

  1. CallScribe produces a suggested answer.
  2. It writes a small JSON file, {"title": ..., "text": ...}, into Prompter’s inbox folder, under a temporary name: sec-<timestamp>.tmp.
  3. It renames the file to .json, atomically.
  4. Prompter, which watches the folder, sees a complete file, turns it into a new section, jumps to it, and deletes the file.
CallScribe                              Prompter
──────────                              ────────
answer ready
  │
  ├─ write  sec-1699….tmp ──┐
  │                          ▼
  ├─ rename sec-1699….json ──►  inbox watcher sees a COMPLETE file
  │                               │
  │                               ├─ new section appears
  │                               └─ file deleted
  │
  └─ if nobody collects it in 5 minutes: swept and deleted

Why a folder instead of an API? Because every property I wanted falls out of the filesystem for free:

This is the same architectural instinct I keep meeting in infrastructure work: small explicit contracts between components, boring transport, and failure behavior decided in advance instead of discovered in production.

What Prompter persisted from the demo: its data file holds the delivered sections, titles included. The inbox files themselves were deleted by Prompter as it collected them, which is the delivery working as designed.

Privacy decisions, made in the code

Local-first was the headline, but the smaller decisions are the ones I am prouder of, because they are about what persists after the moment passes.

Audio is never stored. The capture stream exists in memory, is transcribed, and is discarded. There is no recording file to leak, subpoena, or forget about. Transcript logging is also off by default in the shipped configuration.

Section titles are a privacy surface. Here is the subtle one. Prompter saves its sections to disk. That is a feature; it is how your material survives a restart. But it means a section title survives too. If the title is the other person’s question, their words are now sitting in a data file on my machine indefinitely. So the bridge has two title modes: question mode, which embeds a trimmed version of the question because it makes sections scannable, appropriate for rehearsal with fictional material, and timestamp mode, which titles sections [14:32] Answer and keeps the other party’s words out of the persistent file entirely. The mode exists because I traced where the data flows after the call ends, not just during it.

Uncollected answers delete themselves. If Prompter never collects an inbox file (it was closed, it crashed, the moment passed), CallScribe sweeps the inbox and deletes anything older than five minutes. The alternative, an inbox that quietly accumulates every answer I was ever given, is the kind of data pile you only notice when it embarrasses you.

The AI boundary is a switch, not a vibe. ai_answers_enabled ships as false. With it off, no text leaves the machine, and in the demo that is a testable claim, not a promise: with AI off, the transcript appeared and the answer pane stayed empty; the inbox stayed empty; Prompter gained no section, because the bridge is fed by the answer pipeline and the answer pipeline was off. The transcription still ran. The boundary is visible in the filesystem.

Test A of the demo, AI off: the scripted first line is transcribed, the answer pane is empty, and nothing was asked.

What happens when the other side isn’t there

I designed for four absences:

None of these behaviors required heroics. They required deciding, in advance, what each component owes the others, which is the whole argument for explicit contracts in the first place.

Measuring instead of guessing

The project ships its own evaluation path: give it a WAV file and the exact reference text, and it transcribes the clip with the production settings and reports how the output compares:

python -m callscribe --transcribe sample.wav --reference truth.txt

The evaluator is deliberately strict about its input: uncompressed 16-bit PCM WAV, or it refuses the file. (I know because my first recording was an m4a from my phone, and it refused that.) Local, explicit, and unimpressed by convenience: that is the right temperament for a measurement tool.

The live demo had already taught the same lesson the evaluation was about to quantify. On one run, the transcription misheard the word “cache” as “cachet”. Nothing downstream checked it. The model answered the word it was actually given (its answer politely noted that “cachet” can mean different things), and the section title saved the typo into Prompter’s data file, where it will live until I delete the section. One wrong word, propagated faithfully through three components that were each working exactly as designed. A pipeline does not know the difference between data and an error; it moves both with the same reliability. That is worth designing for, and it is why the number below gets published with its causes attached instead of rounded into a claim.

Here is the actual output of that command, run on the general build on my own machine, on a 21.8-second clip of my own voice reading the fictional three-line script used throughout the demo. No real conversation, nothing cherry-picked:

MeasurementResult
Enginefaster-whisper, large-v3-turbo, CUDA / float16
Audio length21.8 s
Total decode time1.13 s
Real-time factor0.052, about 19× faster than real time
Word error rate vs. the script0.132 (13.2%)

And here is what the errors actually were, because the aggregate number hides them: “Northwind Studio” came back as “North Green Studio,” “serve” became “save,” and the fixed-length chunking the evaluator uses dropped the opening words of the third line at a chunk boundary. In other words, the model handled ordinary speech essentially perfectly and stumbled on exactly the two things you would predict: a proper name it had never seen (which is what the configuration’s whisper_initial_prompt vocabulary hint exists for), and a decoding boundary artifact of the harness rather than the live pipeline. I am publishing the 13.2% as measured, with its causes attached. A number whose failure modes you can name is worth more than a smaller number you cannot explain.

The evaluation output itself: the engine, decode timing, real-time factor, transcript, and word error rate printed by the project’s own command.

The offline smoke suite gets the same treatment: it encodes the decisions as checks, covering the bridge contract, the provider behaviors, and, my favorite, a guard that the configuration from other builds does not leak into this one. Configuration drift between builds is exactly the sort of thing that “works on my machine” hides until it matters.

What I would do next

  1. Package the demo as a repeatable script. The synthetic run (fictional script, AI off, AI on, evaluation) should be one documented procedure anyone can re-run to reproduce every claim in this post.
  2. Publish the general build’s repository cleanly. The code deserves a public repo with the configuration examples, the privacy document, and the smoke suite visible. The engineering is the portfolio.
  3. A small latency budget, written down. The system has informal targets (instant for notes, a couple of seconds streamed for the model). Writing them as a budget with the measurement method would turn “feels fast” into something checkable.
  4. Keep the builds separate. The general build stays free of context-specific features. The moment a general tool grows a mode for one environment, its privacy story and its test surface both get harder to reason about. I would rather maintain the boundary than the exception list.

References

Nasim Bayati: cloud engineer working toward AWS Solutions Architect Associate (SAA-C03), focused on infrastructure, networking, security, and systems that explain themselves.