Local API
A small HTTP API that lets any script or agent on your Mac read your dictations, or wait for the next one. Everything else (the CLI and the MCP server) is built on it.
Basics#
| Base URL | http://127.0.0.1:47820 |
|---|---|
| Port | Usually 47820. If it’s taken, the app uses the next free port up to 47829 and writes the one it chose to the config file. |
| Format | JSON, UTF-8. Keys are snake_case. Dates are ISO 8601 in UTC. |
| Version | Every path starts with /v1. GET /v1/status reports api_version. |
| Availability | On by default while Macrophone for Mac is running. Turn it off in Macrophone’s Developer pane. |
The API listens on 127.0.0.1 only, so nothing else on your network can reach it.
Authentication#
Every request needs a bearer token that starts with mp_:
Authorization: Bearer mp_…Find the token in the Mac app's main window under Developer, where you can also generate a new one (which stops the old one working). The app also writes the port and token to a config file only your user account can read, so local tools never need you to copy it by hand:
{"port":47820,"token":"mp_…"}To use it from a shell:
CFG="$HOME/Library/Application Support/Macrophone/api.json"
MP_PORT=$(plutil -extract port raw "$CFG")
MP_TOKEN=$(plutil -extract token raw "$CFG")
curl -s -H "Authorization: Bearer $MP_TOKEN" "http://127.0.0.1:$MP_PORT/v1/latest"Treat the token like a password. Anything holding it can read your recent dictations. Don’t commit it to a repository or paste it into a website.
Browser lockdown#
Web pages in your browser can also make requests to 127.0.0.1. To make sure no website can ever read what you dictate, the API refuses anything that looks like it came from a browser:
- Any request with an
OriginorSec-Fetch-Siteheader gets403. - The
Hostheader must be127.0.0.1:<port>orlocalhost:<port>, which blocks DNS rebinding. - No CORS headers are ever sent, and
OPTIONSrequests get403.
curl, Python's urllib and requests, Node's built-in fetch, Swift and Go clients don't send these headers, so they work as normal. Code running in a browser, including a browser extension's page, cannot use the API.
When history is off#
If you turn history off in the Mac app, nothing is written to disk. The API still answers from memory: /v1/latest returns the most recent dictation since the app started, and /v1/history returns at most that one item.
The dictation object#
Every endpoint that returns a dictation uses this shape. Every key is always present; cleaned, profile and reply are null when there's nothing to put there.
{
"cleaned": "Refactor the upload handler so retries back off exponentially, and add a test for the timeout case.",
"cleanup_level": "medium",
"created_at": "2026-10-12T20:41:03Z",
"delayed": false,
"id": "6F1C2A7E-3B4D-4C55-9A0E-2D8F1B7C9E10",
"language": "en-US",
"profile": "Claude Code (Terminal)",
"raw": "So um refactor the upload handler so that it no so retries back off exponentially and add a test for uh the timeout case",
"received_at": "2026-10-12T20:41:04Z",
"reply": null,
"source_device": "Alex's iPhone",
"stopped_early": false,
"text": "Refactor the upload handler so retries back off exponentially, and add a test for the timeout case."
}| Field | Type | Meaning |
|---|---|---|
id | string (UUID) | Stable across resends and queued retries. Use it to de-duplicate, or as after for /v1/next. |
text | string | What the phone delivered: the cleaned text when there is one, else the raw transcript. Snippets, the output format and the template are not applied here; that version is what went to the app, pane or action. |
raw | string | Exactly what the speech recognizer heard. |
cleaned | string or null | The cleaned-up text, or null when cleanup was off or unavailable. |
cleanup_level | string | off, light, medium or heavy. |
language | string | BCP 47 language tag, such as en-US. |
profile | string or null | The profile that ran on the Mac, after a voice prefix or per-app rule picked it. |
created_at | string | When you finished speaking, on the iPhone. |
received_at | string | When the Mac received it. |
delayed | boolean | true if it waited in the phone’s offline queue. |
stopped_early | boolean | true if recording was interrupted (a call, the screen locking). What was captured was still sent. |
source_device | string | The iPhone’s name. |
reply | string or null | Claude’s answer, when the profile has an Ask Claude action. null until the reply has finished streaming, and for every other dictation. |
Getting Claude’s reply#
A dictation shows up on /v1/next and /v1/stream the moment it arrives, before Claude has answered, so reply is null there. Read /v1/latest or /v1/history a few seconds later to get it. With history off, the reply is held in memory with the latest item only.
mp wait --json | jq -r .text # your words arrive (reply is still null here)
sleep 10 # give Claude a moment to answer
mp latest --json | jq -r .reply # Claude's answerEndpoints#
| Method and path | Returns |
|---|---|
GET /v1/latest | 200 one dictation, or 204 if there isn’t one yet |
GET /v1/history?limit=N | 200 {"items":[…]}, newest first. N is 1–50, default 10 |
GET /v1/next?timeout=S&after=<id> | Long-poll. 200 the next dictation, or 204 on timeout. S is 1–300 seconds, default 60 |
GET /v1/stream | Server-sent events, one dictation event per arrival |
GET /v1/status | 200 app version, API version, history setting and phones |
GET /v1/latest#
The most recent dictation, or 204 No Content if nothing has arrived yet.
curl -s -H "Authorization: Bearer $MP_TOKEN" http://127.0.0.1:47820/v1/latestGET /v1/history#
Recent dictations, newest first. limit is 1 to 50 and defaults to 10.
curl -s -H "Authorization: Bearer $MP_TOKEN" "http://127.0.0.1:47820/v1/history?limit=5"{"items":[ { …dictation… }, { …dictation… } ]}GET /v1/next#
Waits for a dictation and returns it as soon as it arrives. Use it when an agent or script should react to what you say next.
timeout: how long to wait, 1 to 300 seconds, default 60. On timeout you get204.- Without
after, it returns the next dictation that arrives after the request was made. - With
after=<id>, it returns immediately if anything newer than that id is already there, otherwise it waits. Pass the last id you handled and you'll never miss one between requests.
curl -s -H "Authorization: Bearer $MP_TOKEN" "http://127.0.0.1:47820/v1/next?timeout=120"Set your HTTP client’s own timeout a little longer than timeout, or it may give up before the API answers.
GET /v1/stream#
A text/event-stream that stays open and sends one event per dictation as it arrives, plus a keep-alive comment every 15 seconds.
curl -sN -H "Authorization: Bearer $MP_TOKEN" http://127.0.0.1:47820/v1/stream: keep-alive
event: dictation
data: {"cleaned":"Ship it.","cleanup_level":"medium","created_at":"2026-10-12T20:44:10Z", … ,"reply":null, … }GET /v1/status#
{
"app_version": "1.0",
"api_version": 1,
"history_enabled": true,
"phones": [
{ "name": "Alex's iPhone", "connected": true, "last_seen": "2026-10-12T20:41:04Z" }
]
}Errors#
Errors come back as JSON with a single error message:
{"error":"…"}| Status | Meaning |
|---|---|
401 | The token is missing or wrong. |
403 | The request looked like it came from a browser (an Origin or Sec-Fetch-Site header, a foreign Host, or OPTIONS). |
404 | Unknown path. |
405 | Wrong method. Every endpoint is GET. |
If the connection is refused, Macrophone for Mac isn't running or the API is turned off in its Developer pane.
Examples#
Python#
Standard library only. Prints every dictation as it arrives, without ever missing one.
import json, pathlib, urllib.request
cfg = json.loads((pathlib.Path.home() / "Library/Application Support/Macrophone/api.json").read_text())
base = f"http://127.0.0.1:{cfg['port']}"
def get(path, timeout=10):
req = urllib.request.Request(base + path, headers={"Authorization": f"Bearer {cfg['token']}"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return None if r.status == 204 else json.load(r)
latest = get("/v1/latest")
after = latest["id"] if latest else None
while True:
query = "/v1/next?timeout=300" + (f"&after={after}" if after else "")
item = get(query, timeout=310)
if item:
after = item["id"]
print(item["text"], flush=True)Node.js#
Node 18 or later, no packages. Reads the stream and logs each dictation.
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
const cfg = JSON.parse(await readFile(`${homedir()}/Library/Application Support/Macrophone/api.json`, 'utf8'));
const res = await fetch(`http://127.0.0.1:${cfg.port}/v1/stream`, {
headers: { Authorization: `Bearer ${cfg.token}` },
});
const decoder = new TextDecoder();
let buffer = '';
for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true });
let end;
while ((end = buffer.indexOf('\n\n')) !== -1) {
const event = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
const data = event.split('\n').find((line) => line.startsWith('data: '));
if (data) console.log(JSON.parse(data.slice(6)).text);
}
}curl and jq#
# The raw transcript of the last dictation
curl -s -H "Authorization: Bearer $MP_TOKEN" http://127.0.0.1:47820/v1/latest | jq -r .raw
# Texts of the last 10, oldest first
curl -s -H "Authorization: Bearer $MP_TOKEN" "http://127.0.0.1:47820/v1/history?limit=10" | jq -r '.items | reverse | .[].text'For most shell work the command-line tool is simpler: it finds the port and token for you.