For developers

Use utt's HTTP API to transcribe audio from another process or device. Build an extension to send local audio, receive transcripts, filter text, or add settings and status to the utt window. Both interfaces work without an SDK.

The transcription API

The HTTP API lets a phone, script, or app send audio to utt and receive a transcript. utt always runs the speech model on your Mac, even when the recording comes from another device.

Off by default. Switch it on under Settings, Connect, API.

Reach

The Reach setting controls who can connect. utt binds the listener to the matching network interface and checks every caller's address.

SettingBinds toAccepts
This Mac only127.0.0.1Loopback.
This Mac and my local networkAll interfacesLoopback, link-local, and any peer on a subnet one of this Mac's own interfaces is on.
AnywhereAll interfacesAnything that reaches the port.

"Local network" is decided against the Mac's own interfaces, not against private address ranges. A VPN peer has a private address and is far away. A phone on the same Wi-Fi is often globally addressed over IPv6.

There is no TLS. Put the API behind a reverse proxy or a VPN if the traffic leaves your own network. If the port is already taken the listener never starts, and the settings card says so.

Auth

Every request, /health included, carries a bearer token:

Authorization: Bearer <token>

The token is generated the first time the API is switched on and shown in the settings card. "New" replaces it and locks out every caller set up with the old one. An empty token means the server does not start at all.

/docs also accepts ?token=…, because a browser address bar cannot set a header. Nothing that carries audio does.

Endpoints

The base URL is http://<host>:<port>. Use 127.0.0.1 for "This Mac only", otherwise the Mac's Bonjour name such as mac.local, which outlives its IP address.

GET /docs

The OpenAPI reference, rendered by Redoc and served by utt itself, so it always describes the build that is answering. The settings card's API reference link opens it with the token already in the URL.

GET /health

Returns { "ok": true, "version": "2.0.0" }.

POST /transcribe

The body is the audio file itself. No multipart, no JSON envelope, no fields.

  • Content-Type picks the reader: audio/wav (the default for anything unrecognised), audio/mp4, audio/mpeg, audio/aiff, audio/flac or audio/caf.
  • 25 MB maximum, refused before the body is read.
  • Clips shorter than 0.3 s are rejected by the engine.
  • The engine wants 16 kHz mono. It resamples anything else, so record at 16 kHz mono where you can.
  • X-Utt-Hints is an optional comma-separated list of names or terms you expect in the recording, such as a repository, product, or person. utt only applies a hint when the recognised word is already a close match. A missed correction is safer than replacing the wrong word.
curl -X POST http://mac.local:8756/transcribe \
  -H "Authorization: Bearer $UTT_TOKEN" \
  -H "Content-Type: audio/wav" \
  --data-binary @clip.wav

JSON

{
  "text": "Hello world, this is a test of the transcription API.",
  "raw": "hello world this is a test of the transcription api",
  "stages": ["formatting", "replacements"],
  "timings": {"decode": 1802.5, "replacements": 0.4, "formatting": 0.2}
}

The transcript has been through the same pipeline the hotkey uses: the engine and model named in Settings, then the word replacements, the cleanup stage if the user has it on, and the formatting rules. A caller gets exactly what utt would have pasted.

text is that transcript and does not change shape, so a client written against the older single-field reply keeps working. Beside it:

  • raw: what the recogniser heard, before every stage and before your own hints. Equal to text when nothing changed it.
  • stages: which stages actually changed the words: replacements, cleanup, formatting, filter (an extension the user installed) and hints (yours). Empty means what was heard is what you got. Hints are named as a stage rather than folded into raw, because raw stays the one thing a caller cannot reconstruct.
  • timings: what each stretch of work took, in milliseconds, plus decode, the recogniser, where most of the wait goes. A stage that ran and left the words alone is in here and not in stages; it took just as long either way. They do not sum to your round trip.
  • cleanupSkipped: only when the user has cleanup on and it did not run on this clip: unavailable, guardrail, timeout, tooLong, tooShort or failedVerification. The transcript landed anyway.

Errors are { "error": "…" } with a status:

400Not audio, or an empty body.
401Missing or wrong token.
404No such path.
413Body over 25 MB.
500The engine failed.

One request per connection. The response carries Connection: close; there is no keep-alive.

Writing a client

The API settings include a Copy guide button. It copies this Mac's base URL, wire format, audio settings, and a working URLSession client. The brief also warns about common mistakes. It leaves the token as a placeholder so you do not paste the secret into a chat.

Extensions

An extension connects a program running on your Mac to utt. The program can be a daemon, menu bar app, or script. Add a manifest and utt builds its settings page, then writes the user's choices to a file your program can read.

Extensions do not register with utt or call a separate API. Both programs read and write files in the extensions directory. Either program can restart without losing the connection.

~/Library/Application Support/dev.jurrejan.utt/extensions/

Before utt 1.1.2, this directory was named plugins/. utt moves the old directory once. Programs that continue writing to plugins/ will no longer reach utt.

What you can build

Each capability has its own manifest key. Ask only for the capabilities your program uses.

What your program doesDeclare or writeWhat utt provides
Sends local audio for transcriptionsendsAudioA jobs folder for clips and answers.
Receives every finished transcriptwantsTranscriptsThe newest final transcript in one file.
Follows words while the person speakswantsPartialsA stream of provisional text through one file.
Changes text before it is deliveredfiltersTranscriptsA two-second request and reply exchange.
Adds configuration and commandssettings and actionsControls on the extension's page and in its optional menu.
Shows whether its process is healthydaemon and status.jsonLive status, a restart action and an optional menu.
Calls the HTTP APIneedsApiThe token and port in the values file while the API is on.

Use skipsTextStages when your extension needs a literal transcript. The setting applies only to audio sent by that extension. It never changes the user's own dictation.

FileWritten byHolds
<id>.jsonYouThe manifest: name, icon, and the settings to draw.
<id>.consent.jsonuttThe user's approval, off state and queue priority.
<id>.values.jsonuttWhat the user chose, every key, every time.
<id>.jobs/BothClips you drop in, transcripts utt leaves beside them.
<id>.transcript.jsonuttThe newest transcript, if you asked for them.
<id>.partial.jsonuttThe provisional words while the person is speaking.
<id>.filter/BothFinished transcripts you may change before they land.
<id>.action.jsonuttThe newest button press from your page or menu.
<id>.status.jsonYouA few lines shown read-only at the top of your page.

The manifest

Write <id>.json whenever your program starts. Rewriting it restores the manifest after an uninstall, settings reset, or deleted extensions directory.

deckhand.json

{
  "id": "deckhand",
  "name": "Deckhand",
  "blurb": "One sentence under your name: what the extension does for the person.",
  "description": "A short paragraph for the About section of your page.",
  "website": "https://example.com/deckhand",
  "repository": "https://github.com/example/deckhand",
  "systemImage": "sailboat",
  "needsApi": false,
  "wantsTranscripts": false,
  "sendsAudio": false,
  "wantsPartials": false,
  "filtersTranscripts": false,
  "skipsTextStages": [],
  "tint": "#3EAFB4",
  "showsInMenuBar": true,
  "daemon": {"label": "com.example.mydaemon"},
  "actions": [
    {"key": "stop", "label": "Stop", "detail": "What it does.", "confirms": true},
    {"key": "openLog", "label": "Open log"}
  ],
  "settings": [
    {"key": "route", "kind": "choice", "label": "Route",
     "options": ["auto", "keyboard"], "value": "auto",
     "detail": "Explains the setting, shown under the label."},
    {"key": "deliver", "kind": "bool", "label": "Send into sessions", "value": true},
    {"key": "prefix", "kind": "string", "label": "Prefix", "value": ""},
    {"key": "delay", "kind": "number", "label": "Delay", "value": 0}
  ]
}
  • id must equal the filename without .json. Lowercase letters, digits, ., _ and - only. A mismatch is ignored.
  • kind is bool, string, number or choice. value is the default and must match the kind. A choice needs options and a default among them.
  • systemImage is an SF Symbol. An unknown one is dropped, not drawn.
  • Only id and name are required. Omitted keys take their defaults; the example above shows every one of them.
  • At most 24 settings and 8 actions. Long labels are trimmed; newlines are flattened.
  • utt ignores a setting it cannot display without changing its meaning.

Approval

An extension is installed by placing its manifest in a folder. The manifest is not signed and no installer checks it, so every new extension stays pending until the user approves it under Settings, Connect, Extensions.

While approval is pending, utt does not create a values file, transcribe clips, share transcripts, or expose the API token. Your program cannot approve itself. It must continue safely if approval never arrives. If it submits audio before approval, utt returns an error:

{"error": "utt is waiting for you to approve this extension. …"}

Treat a missing values file the same way: the user has not opened your page yet, or has not said yes. Run on your manifest's own defaults until it appears.

Approval is also where the user places you in the queue, if you send audio: First, In turn or Last. The setting decides which extension gets the next turn when several have queued work. It does not interrupt the current transcription, and each extension's own clips remain in order.

The decision lives in <id>.consent.json, which utt writes and you may read but never write. One you put there is overwritten rather than obeyed, since an extension that could approve itself is the hole this closes:

{"decision": "approved", "decidedAt": "2026-09-10T14:22:07Z", "priority": "normal"}

No file at all means waiting, same as a missing values file. "disabled" means the user approved you once and has since switched you off from your page. It is inert, and the file says which. priority is the file's own word for the queue above: "next", "normal" or "last".

The values file

utt writes <id>.values.json when the user changes something, and once after you install the manifest so the file exists before anyone opens the page.

deckhand.values.json

{
  "revision": 4,
  "values": {"route": "auto", "deliver": true, "prefix": "", "delay": 0},
  "api": {"token": "…", "port": 8756}
}
  • It holds every setting, always. utt does not merge. Read the whole values object; a missing key never means "unchanged".
  • revision goes up by one on every write. Compare it against the last one you saw, not the modification time.
  • A text setting arrives per keystroke, not per commit. A typed sentence is forty revisions. Act on the value you care about, and debounce anything expensive.
  • The write is atomic, so a poll never reads a half-written file. Once a second is plenty.
  • api appears only if your manifest set needsApi, the user has approved you, and the API is on. Its absence means "not available right now". Never read utt's own settings file.

Poll it, and treat a missing file as "the user has not opened the page yet":

struct Settings: Decodable { var route: String; var deliver: Bool; var prefix: String; var delay: Double }
struct Values: Decodable { var revision: Int; var values: Settings }

let file = URL.homeDirectory.appending(path: "Library/Application Support/dev.jurrejan.utt/extensions/deckhand.values.json")
var seen = 0
while true {
    if let data = try? Data(contentsOf: file),
       let doc = try? JSONDecoder().decode(Values.self, from: data),
       doc.revision != seen {
        seen = doc.revision
        apply(doc.values)  // every key, every time
    }
    try await Task.sleep(for: .seconds(1))
}

Sending audio

Set "sendsAudio": true and utt creates <id>.jobs/. The file exchange stays on the Mac and works while the HTTP API is off. Use the jobs folder for local audio instead of opening a network connection.

  1. Write the clip under a name that is not yet an audio file, such as clip-1.wav.part, then rename it to clip-1.wav. utt only picks up audio extensions, so a half-written file is invisible until the rename makes it whole.
  2. utt transcribes it and writes clip-1.json beside it, atomically. Exactly one of text and error is present.
  3. utt deletes the audio after success or failure. Your program must read and delete the answer file.
let jobs = URL.homeDirectory.appending(path: "Library/Application Support/dev.jurrejan.utt/extensions/deckhand.jobs")
let part = jobs.appending(path: "clip-1.wav.part")
try wav.write(to: part)
try FileManager.default.moveItem(at: part, to: jobs.appending(path: "clip-1.wav"))  // now utt sees it

let answer = jobs.appending(path: "clip-1.json")
while !FileManager.default.fileExists(atPath: answer.path) {
    try await Task.sleep(for: .milliseconds(200))
}
struct Answer: Decodable { var text: String?; var error: String? }
let result = try JSONDecoder().decode(Answer.self, from: Data(contentsOf: answer))
try FileManager.default.removeItem(at: answer)
print(result.text ?? result.error ?? "")

clip-1.json

{
  "text": "the words that were spoken",
  "raw": "um the words what were spoken",
  "stages": ["cleanup", "replacements"],
  "startedAt": "2026-09-09T17:04:09Z",
  "finishedAt": "2026-09-09T17:04:11Z",
  "startedAtMs": 1789052649182,
  "finishedAtMs": 1789052651511,
  "duration": 3.4,
  "timings": {"decode": 1802.5, "replacements": 0.4, "cleanup": 511.2}
}

{"error": "Could not transcribe that clip.", "startedAt": "2026-09-09T17:04:09Z",
 "finishedAt": "2026-09-09T17:04:11Z", "startedAtMs": 1789052649182, "finishedAtMs": 1789052651511}
  • Extensions utt opens: wav, m4a, mp3, aiff, flac, caf. The extension picks the reader, so it must match the bytes.
  • Clips are picked up oldest first, so two sent in order come back in order.
  • 25 MB maximum. You get the same text the hotkey would have pasted.
  • text is the transcript; raw, stages, cleanupSkipped and timings say what was heard and which stages changed it, exactly as the API reply does, with one more stage name of its own: hints, for the file below. startedAt, finishedAt and their millisecond twins startedAtMs/finishedAtMs are always there, success or error; the rest are not.
  • Drop clip-1.hints.json, a flat JSON array of strings, beside the clip before you rename it, and utt corrects the transcript against those terms.

Transcripts

Set "wantsTranscripts": true and every transcript utt produces is written to <id>.transcript.json as it finishes.

deckhand.transcript.json

{
  "sequence": 12,
  "text": "the words that were spoken",
  "raw": "um the words what were spoken",
  "stages": ["cleanup", "replacements"],
  "finishedAt": "2026-09-09T16:58:03Z",
  "duration": 3.4,
  "timings": {"decode": 1802.5, "replacements": 0.4, "cleanup": 511.2},
  "app": "Ghostty"
}
  • The newest one only. This is not a log; utt already keeps the history.
  • sequence goes up per transcript. Poll it exactly as you poll revision.
  • app is where the text was pasted, and is absent when nothing received it.
  • raw, stages, cleanupSkipped and timings are the same fields the jobs answer carries, except for its hints stage because dictation has no hints file. There is no startedAt here, only finishedAt and duration.
  • Written whether or not the user keeps history.
  • This gives the extension every transcript produced on the Mac. utt names that access when asking the user for approval and shows it on the extension's page. Do not request it unless you need it.

Other capabilities

Each of these is one more key in the manifest, and one more file or directory utt starts keeping for you.

  • wantsPartials: the words as they are decoded while the key is still held, written to <id>.partial.json. A different, smaller, English-only recogniser produces them before any of utt's text stages, and only while the person has "Show words while you speak" on. The real transcript is a separate, later event and may say something else entirely. Nothing here is final.
  • filtersTranscripts: every finished transcript is written to <id>.filter/<name>.in.json, and whatever you write back to <name>.out.json within two seconds is what lands. A missed deadline is not a failure: the original goes through. An empty string drops the transcript.
  • skipsTextStages: stages of the user's own text pipeline skipped for your clips only, never for their dictation. The options are replacements, cleanup and formatting. An unknown name is dropped rather than refusing the manifest.
  • actions: up to eight buttons on your page. Pressing one writes <id>.action.json with a sequence you poll exactly as you poll revision. "confirms": true makes utt ask first.
  • daemon: a launchd label. utt reads its state from launchd and offers a Restart button. It never launches, loads or unloads anything on your behalf. It takes a label rather than a path to a plist and refuses a label beginning com.apple.. You may report on your own daemon but not reach into the system's.
  • showsInMenuBar: a submenu inside utt's own menu bar menu, built from your status lines, your daemon and all of your actions. All of them or none; there is no per-action opt-in.

tint, description, website and repository control how the extension appears in utt. They set the menu bar colour used during its jobs and the About section on its page. Website and repository links must use https://.

Status

You may also write <id>.status.json: a flat object of strings, shown read-only at the top of your page.

deckhand.status.json

{"daemon": "up 0.1.0", "sessions": "9 live", "lastRelay": "2 min ago"}
  • utt never interprets these. camelCase keys are split for display, so lastRelay becomes "Last relay".
  • A missing file means "not running", and that is how it is shown.
  • Rewrite it when something changes, at most about once a second.

Build an extension

  1. Write the manifest at every start-up.
  2. Poll the values file and act when revision moves. Until it exists, use your manifest's own defaults. It may be missing because nobody has approved you yet.
  3. Use the jobs directory to transcribe audio. Reach for needsApi only if you need the HTTP API for something else, such as talking to utt from another device.
  4. If you do need the API, take the token from the values file. Never read utt's settings.json.
  5. Ask for what you use and nothing more. Every flag in the manifest is read out to the user in plain words before they decide.
  6. Nothing in the values file is a command. It is the user's configuration, and it is the only thing utt promises to put there.

Do not put secrets of your own in the manifest. It is a plain file, and its contents are shown in utt's window.

The Extensions page in utt has a Copy guide button. It copies an LLM brief based on this guide and fills in the extensions directory for the current installation.