heypoul

beta

Voice-driven dictation with a reviewable draft, and live two-track meeting transcription, on Azure AI Foundry's MAI-Transcribe-2.

Author: Poul Kjeldager
Usage: pks exec heypoul [meeting] [flags]
Category: agents

Examples

$ pks exec heypoul

Push-to-talk dictation with Foundry access wired in by pks

$ pks exec heypoul -- models

The chat models pks' catalog offers across its registered providers, with pks' default and the decision model marked

$ pks exec heypoul -- decide --option gpt-5.2 --option gpt-6-astra --option gpt-5.6-luna "den nyeste"

What the grammar and the decision model make of a spoken answer

$ pks exec heypoul -- clean --model gpt-5.5 "øhm vi sender det mandag, nej tirsdag"

See what a cleanup deployment does to dictated text

$ pks exec heypoul meeting

Live transcript of a Teams call: the other side via loopback, you via the microphone

$ heypoul --list-outputs

List output devices that meeting mode can capture

$ heypoul --aec-probe 10

Diagnose the Windows echo canceller the meeting's Dig track uses (endpoints, samples, HRESULTs, a WAV)

heypoul turns speech into text with Microsoft's MAI-Transcribe-2 on Azure AI Foundry. It does two things:

  • Push-to-talk dictation. Hold a key, speak, and the words appear in the overlay while you talk (MAI-Transcribe-2-Streaming). Release, and the text lands in a draft you can read, extend, undo and clean up before you say "hey poul send" and it is typed into the focused window (see Draft mode). On Windows the key is Right Alt unless you pass --key (a virtual-key code, e.g. 0x78 for F9) or set HEYPOUL_KEY.
  • Meeting mode. heypoul meeting transcribes a call live on two tracks, labelled Dem (what your speakers or headset play, captured by WASAPI loopback) and Dig (your microphone). You keep hearing the call as normal; there is no virtual audio device to install.

While it runs, a small pill sits at the bottom of the screen (drag it anywhere): a state orb, the live waveform, and the words as they arrive. Idle it shows which key to hold; after a release it shows what was typed.

Install

Windows (PowerShell):

irm https://agentics.dk/install/heypoul.ps1 | iex

Linux:

curl -fsSL https://agentics.dk/install/heypoul.sh | bash

Released builds are amd64 and cloud-only.

Run it through pks

The recommended way to give heypoul access to Foundry is pks exec. Sign in once with pks foundry init, then:

pks exec heypoul
pks exec heypoul meeting

heypoul never receives an Azure token. pks starts a credential proxy on 127.0.0.1 in front of your Foundry resource and gives heypoul its address and a throwaway token that only opens that proxy. The proxy attaches a fresh Entra token to every request and every WebSocket, so a meeting can run longer than the token's one-hour lifetime.

Without pks, set HEYPOUL_ENDPOINT and either HEYPOUL_API_KEY or HEYPOUL_TOKEN yourself.

Draft mode

Since 0.3.0 dictation is staged: nothing is typed when you release the key. The text goes into a draft, the end of it shows in the pill with a ▴, and you drive it by voice. Say the wake word "hey poul" (also heard as "hej poul", "hey Paul") followed by a command, alone or at the end of a sentence:

SayDoes
hey poul sendTypes the draft into the focused window
hey poul enterTypes the draft and presses Enter; with an empty draft just presses Enter (confirms a prompt in Claude or a terminal)
hey poul vis tekst / showOpens the panel: the whole draft, wrapped, with the live words in orange while you hold the key
hey poul skjul / hideBack to the pill
hey poul fortryd / undoDrops the last hold
hey poul ryd / start forfra / clearDrops the whole draft
hey poul skift model / vælg modelAsks which model with a four-option choice: three models and Flere… for the next three. Answer without the wake word (see Choices)
hey poul modeller / modelsShows the whole list: every chat deployment on your resource, numbered, the one in use in orange
hey poul model 2 / brug luna / skift til gpt 5.5Switches the cleanup model, by number in the full list or by name; saved to config.json
hey poul rens / ryd op / cleanRuns the cleanup model now
hey poul hjælp / kommandoer / vis kommandoer / helpShows this list in the panel; hey poul skjul closes it
hey poul lyt med / start mødet / listenStarts the meeting inside the daemon and opens the transcript view (see Meeting mode)
hey poul stop mødet / slut mødet / stop listeningStops the meeting
hey poul indstillinger / settingsOpens the settings view: language, models, key, word list, devices
hey poul tilføj ordet Gjellervand / add the word …Adds a word to the word list, spelled as you said it
hey poul brug kort 2 / fjern kort 2 / use card 2Puts an insight card's text into the draft, or drops the card. Cards come from the meeting transcript (every 20 s, at most four) and show in a running pks-agent-notch when there is one, else in the meeting view

"Hey poul" inside a sentence stays text when no command follows it. A command at the end of a hold is split off: "Vi tager det i morgen, hey poul send" adds the sentence and sends.

Without the wake word. A hold that is nothing but a command runs it: "Send.", "vis teksten", "fortryd", "skift model" — up to three words, so "send mig lige en mail" is dictation. Inside a longer hold the same bare words run only when they are reversible (show, hide, undo, clean, the model commands); send, enter and ryd in the middle of speech still need hey poul in front, or they are text.

Read at every pause. The stream completes a run of speech at each pause (about half a second of silence), and heypoul reads each run on its own as it completes, before the key is released: a command in the middle of a hold first lands what was said before it, then runs, so "… [pause] hey poul vis tekst [pause] og så videre" opens the panel between the two pieces of text. A wake word followed by words the grammar does not know ("Hey Paul, vi teksten" is how vis teksten is sometimes heard) is sent to the decision model right away, with the command list as the options and none among them; a confident command runs when the hold lands, anything else stays text, wake word and all. Corrections to the content ("nej, ret det til tre") need no command at all: the cleanup model applies them.

Cleanup. The draft is rewritten by a chat deployment after every hold: fillers and false starts go, and self-corrections are applied, so "to, nej faktisk tre tusind" becomes "tre tusind" and "ret kunden til Andel" replaces the name. Each cleanup reads the previous cleaned text followed by the new hold, not the raw words from the start, so a correction applied once stays applied and the panel never flashes the old words while the model works; the header says renser meanwhile. The panel always shows exactly what send will type. A name the model picker cannot match ("skift mod den") opens the four-option choice instead of failing.

Choices. Whenever heypoul needs a decision from you it asks the same way: a title and at most four numbered options fill the panel, and the next hold answers it, no wake word needed. Say the number ("to", "nummer tre", "den anden"), the name or enough of it ("luna", "gpt fem to"), or "flere" for the next page. Those the grammar reads on its own, and it reads them from the live words, so a "tre" is taken while the key is still down. Anything else ("den nyeste", "nej ikke luna, den anden") goes to the decision model, which answers in a quarter second with a confidence; below 0.85 heypoul says ikke forstået · sig et tal and keeps asking. A confident none (you went on dictating over the question) closes it and keeps your words. "annuller", "luk" or "hey poul skjul" close it. Four options, numbered, is a habit that carries to a car or a phone unchanged.

Which model. Nothing needs configuring: at start heypoul reads the model catalog pks serves at /pks/v1/models (every deployment on your resource, with the default and the enabled set from pks foundry select) and takes pks' default when it can chat, else the newest plain GPT deployment. The panel header names it. hey poul skift model asks with a choice; hey poul brug luna switches by name; either way one probe request first proves the deployment answers (the Claude deployments on a Foundry resource do not on this route; the HUD says svarer ikke and keeps the old model). A spoken switch is saved to config.json; HEYPOUL_TEXT_MODEL overrides it.

The decision model is the deployment pks foundry select names as Voice classifier model when that is a Decision model (Decision-1), else any Decision deployment on the resource, else none, in which case answers must be a number or a name. HEYPOUL_DECISION_MODEL overrides. From the shell:

pks exec heypoul -- models          # numbered, * = in use, [pks default], [judges answers]
pks exec heypoul -- models --json   # OpenAI list shape with enabled, roles, output
pks exec heypoul -- decide --option gpt-5.2 --option gpt-6-astra --option gpt-5.6-luna --option Flere "den nyeste"

With pks ≥ 7.10.0 the list is pks' catalog across every registered provider (Foundry, Gemini, Anthropic, OpenAI, Moonshot), each row saying who serves it, and cleanup goes through pks' chat route so heypoul never holds a key. The switch flow in pictures, the voice answers and the fallback for older pks are in the tutorial Skift model med stemmen. The catalog's shape, where it comes from and the measured Decision-1 verdicts are in docs/model-catalog.md.

Try a deployment first:

pks exec heypoul -- clean --model gpt-5.5 "øhm vi sender det mandag, nej tirsdag"

gpt-5.5 and gpt-5.6-luna both answer in 1.5–2 s on a 40-word draft.

Direct mode. HEYPOUL_MODE=direct (or "mode": "direct" in config.json) types the text on release as before 0.3.0; voice commands are off there.

Clicks

The panel is clickable since 0.9.0: ▴ opens it, the button at the header's right closes it, a row of a choice or of the model list picks it, the settings rows open their choices, × on a word-list chip removes the word, stop in the meeting view stops the meeting. A click does exactly what the spoken command does. The overlay should not take the focus from the window you dictate into (WS_EX_NOACTIVATE); if it does on your desktop, start with HEYPOUL_RESTORE_FOCUS=1 and the focus is put back before each injection, and say so.

Settings

hey poul indstillinger, or Settings… in the tray, opens the settings view in the panel. Each row is a four-option choice, so it is answered by voice or by click; tryk ny tast waits for the next key you press. Changes land in config.json at once and apply without a restart.

Panel height and idle. Drag the panel's top edge (the small bar) to make it taller, up to the top of the screen, or shorter, down to 240 dp; the height is saved as panelH in config.json (0 = the built-in 420). After eight seconds with nothing happening and the pointer elsewhere the panel dims to about half; any change, a spoken word or a hover brings it back. The pill never dims.

Meeting mode

In the daemon: hey poul lyt med, or Lyt med in the tray, or a click on stop / the green dot to end it. The meeting view shows the newest sixteen lines of both tracks, the unfinished sentence of each in orange, and the header carries a green lytter dot in every view. Push-to-talk keeps working; what you dictate also lands in the Dig track. The same engine runs standalone for scripts:

pks exec heypoul meeting

Each meeting gets a folder (Documents\heypoul\meetings\<date_time> on Windows) with:

FileContent
them.wav, me.wav16 kHz mono recordings of each track
live.mdReadable live transcript, [hh:mm:ss] Dem/Dig: …
live.jsonlOne JSON line per final segment

The live text is fast but has no phrase list. For names and domain terms, transcribe the WAV files again afterwards in batch mode.

FlagDefaultMeaning
--them-devicedefault outputOutput device to capture (substring of its name; see --list-outputs)
--me-deviceconfigured micMicrophone (substring of its name)
--no-meoffOnly transcribe the other side
--out-dirsee aboveWhere the meeting folder goes
--languageda-DKLanguage of the call
--rotate50mOpen a fresh streaming session this often (the service closes them after an hour)

On loudspeakers the microphone also picks up the other side, so on Windows the "Dig" track is opened through the operating system's own echo canceller (the Voice Capture DSP, in Windows since Vista); it subtracts what the output device plays and keeps the two device clocks in step itself. If it cannot be opened the header says why and the raw microphone is used; HEYPOUL_AEC=0 skips it, and heypoul --aec-probe 10 prints a JSON diagnosis with the endpoints, the samples that came out and a WAV to listen to. Loopback captures everything the output device plays, including notification sounds and music.

When live words do not appear

With engine=stream the words should show in the pill while the key is held. If they only arrive after release, the stream failed and heypoul fell back to batch transcription. Since 0.2.1 the console (and %TEMP%\heypoul.log on Windows, /tmp/heypoul.log elsewhere) says why, one line per attempt:

stream connected in 412ms (ws://127.0.0.1:53342/mai/v1/realtime?intent=transcription)
text from stream (23 chars, final after 180ms)

or

stream dial failed after 10s: realtime dial ws://…: context deadline exceeded
stream gave no result after 5s (…) — batch transcription instead

A stream rejected the session line means the service refused the session.update; heypoul then falls back to batch at once. The common case is misleading on the wire:

stream rejected the session: invalid_value: PCM input rate must be 24000, or 16000 for MAI transcription. — the resource behind the endpoint does not know MAI-Transcribe-2-Streaming; …

The rate is not the problem. The service answers this when the model name does not resolve on the Foundry resource, which happens when MAI-Transcribe-2-Streaming is not deployed there or the resource is in a region that does not serve it (served from swedencentral, centralus, eastus2 and southeastasia). Batch MAI-Transcribe-2 is available more widely, which is why push-to-talk still works. Fix it on the resource: deploy the model, or run pks foundry init and pick a resource in one of those regions. heypoul meeting --replay-them <wav> exercises the same stream without a microphone and prints the same lines.

Environment

VariableMeaning
HEYPOUL_ENDPOINTFoundry resource endpoint (set by pks exec)
HEYPOUL_TOKEN / HEYPOUL_API_KEYBearer token or resource key (set by pks exec)
HEYPOUL_KEYPush-to-talk key; Windows virtual-key code, default 0xA5 (Right Alt)
HEYPOUL_ENGINEstream (default, live words during push-to-talk) or cloud (batch only)
HEYPOUL_MODEdraft (default: review and send by voice) or direct (type on release)
HEYPOUL_TEXT_MODELChat deployment that cleans the draft; unset picks the catalog's default at start (see Which model)
HEYPOUL_DECISION_MODELDecision deployment that judges spoken answers to a choice; unset takes the catalog's (see Which model)
HEYPOUL_REALTIME_RATEPCM rate sent to the streaming service, 16000 (default) or 24000; audio is resampled from the internal 16 kHz
HEYPOUL_LANGUAGEBCP-47 language, default da-DK
HEYPOUL_PHRASESWord list, comma-separated: names, products and jargon the recognizer would spell by ear (at most 100). The batch route and the cleanup model take it; the streaming model does not, so on release the batch route decides the final text while the stream has shown the words
HEYPOUL_TOKEN_CMDCommand that prints a fresh token; meeting mode runs it per session
HEYPOUL_THEM_DEVICEOutput device the meeting's Dem track captures (substring of its name); the settings view sets it too
HEYPOUL_RESTORE_FOCUS1 puts the focus back on the window you dictated from before each injection