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 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.
The Reach setting controls who can connect. utt binds the listener to the matching network interface and checks every caller's address.
| Setting | Binds to | Accepts |
|---|---|---|
| This Mac only | 127.0.0.1 | Loopback. |
| This Mac and my local network | All interfaces | Loopback, link-local, and any peer on a subnet one of this Mac's own interfaces is on. |
| Anywhere | All interfaces | Anything 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.
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.
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.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.wavJSON
{
"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:
400 | Not audio, or an empty body. |
401 | Missing or wrong token. |
404 | No such path. |
413 | Body over 25 MB. |
500 | The engine failed. |
One request per connection. The response carries Connection: close; there is no keep-alive.
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.
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.
Each capability has its own manifest key. Ask only for the capabilities your program uses.
| What your program does | Declare or write | What utt provides |
|---|---|---|
| Sends local audio for transcription | sendsAudio | A jobs folder for clips and answers. |
| Receives every finished transcript | wantsTranscripts | The newest final transcript in one file. |
| Follows words while the person speaks | wantsPartials | A stream of provisional text through one file. |
| Changes text before it is delivered | filtersTranscripts | A two-second request and reply exchange. |
| Adds configuration and commands | settings and actions | Controls on the extension's page and in its optional menu. |
| Shows whether its process is healthy | daemon and status.json | Live status, a restart action and an optional menu. |
| Calls the HTTP API | needsApi | The 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.
| File | Written by | Holds |
|---|---|---|
<id>.json | You | The manifest: name, icon, and the settings to draw. |
<id>.consent.json | utt | The user's approval, off state and queue priority. |
<id>.values.json | utt | What the user chose, every key, every time. |
<id>.jobs/ | Both | Clips you drop in, transcripts utt leaves beside them. |
<id>.transcript.json | utt | The newest transcript, if you asked for them. |
<id>.partial.json | utt | The provisional words while the person is speaking. |
<id>.filter/ | Both | Finished transcripts you may change before they land. |
<id>.action.json | utt | The newest button press from your page or menu. |
<id>.status.json | You | A few lines shown read-only at the top of your page. |
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.id and name are required. Omitted keys take their defaults; the example above shows every one of them.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".
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}
}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.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))
}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.
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.clip-1.json beside it, atomically. Exactly one of text and error is present.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}wav, m4a, mp3, aiff, flac, caf. The extension picks the reader, so it must match the bytes.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.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.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"
}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.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://.
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"}lastRelay becomes "Last relay".revision moves. Until it exists, use your manifest's own defaults. It may be missing because nobody has approved you yet.needsApi only if you need the HTTP API for something else, such as talking to utt from another device.settings.json.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.