@miadi/voice-mcp
v0.3.3
Published
MCP server fronting the Miadi voice layer with episode addressing — play, find, publish and mint episode-bound voice from an agent's tool surface.
Downloads
828
Readme
@miadi/voice-mcp
MCP server that lets an agent play, find, publish and mint episode-bound voice — the tool surface over the Miadi voice layer and the Chronicle.
State: four of six tools work. Publish, play, list and resolve run against the live server.
voice_create_episodeandvoice_episode_closing_statusstill refuse and name their contract — they mint and prove a chronicle vessel, and a tool that reports a closing stage it did not prove is worse than one that declines.
The sentence this package exists to satisfy, from William, 2026-08-04:
"What I want is actually to be capable to play the voice-audio created for episodes."
Tools
| Tool | Purpose | State |
|---|---|---|
| voice_publish_to_episode | Produce audio bound to its episode from the moment it exists | works |
| voice_play_episode | Resolve an episode to its voice audio and return playable references | works |
| voice_list_episode_voices | Enumerate and search an episode's voices | works |
| voice_resolve_episode | Answer which episode an agent is inside — with the reason, the alternatives, and an honest null | works, from what it is given |
| voice_create_episode | Mint a vessel when none fits, reporting all five closing stages | refuses, names its contract |
| voice_episode_closing_status | Re-prove the five closing stages, read-only | refuses, names its contract |
Publishing requires a return address
voice_publish_to_episode takes a required origin. A voice nobody can be
steered back through cannot be answered, and that — not the audio — is what the
portal card is for.
"origin": {
"user": "mia", // whoami
"host": "gaia", // hostname -s, not the FQDN
"cwd": "/a/src/Miadi-18", // pwd
"multiplexer": "tmux", // or "herdr". "none" is refused.
"session": "stcbot", // tmux display-message -p "#{session_name}"
"pane": "%127" // $TMUX_PANE — herdr also needs workspace
}Read these values — do not compose them. Inside herdr they are already in your environment and cost nothing:
| field | where it actually is |
|---|---|
| workspace | $HERDR_WORKSPACE_ID |
| tab | $HERDR_TAB_ID |
| pane | $HERDR_PANE_ID |
| session | default when $HERDR_SOCKET_PATH is ~/.config/herdr/herdr.sock, else the <name> in .../herdr/sessions/<name>/herdr.sock |
Inside tmux: $TMUX_PANE and tmux display-message -p "#{session_name}". Read
them in the same invocation that publishes — a pane id copied from another
message is a real id pointing at somebody else's terminal.
The server is the authority, twice over:
- Shape. An absent, incomplete, or
"none"declaration answers400 { error, problems, help }— every missing field at once, plus the shape to send — and no TTS is spent on it. - Truth. When the declaration names the portal's own host and user, it is
probed against the live multiplexer inventory. An address that is not in it is
refused, and the refusal prints what the inventory does hold. This
exists because a required field taught an agent to invent
wH:p2in a session that has no such workspace, and the card drew a Steer button that led nowhere (2026-08-05).
A probe that cannot run — another host, no herdr installed — is unprovable, not
a refusal: it publishes, attested stays "self-declared", and the reason is
recorded on the record. An unreadable inventory proves nothing in either
direction.
reach is the server's verdict, never the caller's claim: declaring a different
host or a multiplexer owned by another user publishes fine and renders as
unreachable, because that is a fact about the topology rather than a defect in
the declaration. attested: "server" means the pane was seen, not that this
persona occupies it — the card says pane live rather than verified, and
verified stays unspent until occupancy itself can be proven.
Resolution consults exactly what it is handed: an explicit reference, a
caller-supplied cwd, or an existing record's binding. It does not rank
those against a session origin or search the wheel — that order is owed by the
voice-episode-binding spec, and inventing one here would make this package a
second source of truth for a rule that has an owner. When nothing given answers,
the tool says so; episode: null with a reason is a valid answer.
Full contracts: SPEC-LINKS.md → /a/src/Miadi/rispecs/voice-mcp/voice-mcp.spec.md.
Architecture
A front, not a second implementation.
- Audio bytes, the KV ledger and the Edge-TTS producer stay behind the Miadi
server (systemd
miadi-server, port 3335), which holds the KV credentials. This process reaches them through@miadi/voice-client, which owns the wire envelope and the token tier — so the HTTP surface is described once, not once per caller. - Episode folders are read from the Chronicle root; the medicine wheel is read
at
http://127.0.0.1:8040. VoiceMessagekeeps exactly one definition in this workspace:@miadi/voice's.- The only wheel write in the whole surface is the registration
mkepisodeperforms duringvoice_create_episode. This server never POSTs or PUTs to the wheel itself.
Configuration
| Variable | Default | Meaning |
|---|---|---|
| MIADI_API_URL | http://127.0.0.1:3335 | Miadi server holding the voice routes |
| MIADI_API_TOKEN_WRITER | — | Read and write. Required to publish. |
| MIADI_API_TOKEN_READER | — | Read only; every publish answers 401 |
| MW_API_URL | http://127.0.0.1:8040 | Chronicle wheel |
| MIADI_CHRONICLE_ROOT | /srv/miadi/episodes/miadi-chronicle | Episode folders |
| MIADI_CHRONICLE_GIT_ROOT | /srv/miadi/episodes | Git root — one level above the chronicle root |
| MIADI_MKEPISODE_PATH | …/mightyeagle/packages/passages/js/mkepisode.js | Vessel creation |
Two wheels exist. http://127.0.0.1:8040 is the Chronicle wheel and the only
correct target for episode work. https://mw.tail3b11eb.ts.net is Gaia's
ceremony wheel and has been offline since 2026-07-29 — an episode registered
there is lost. An implementation refuses that host outright.
MIADI_CHRONICLE_GIT_ROOT is a separate value rather than a derived one because
a git -C pointed at the chronicle root reports a clean tree for a chronicle it
cannot see.
Creating episodes: created is not closed
voice_create_episode performs stages 1 and 4 of the five-stage gate and returns
every stage with its proof:
| Stage | Performed by |
|---|---|
| created | the tool |
| committed | the human — the tool emits the exact git add of named files |
| pushed | the human |
| registered | the tool |
| receipt-verified | the tool |
mkepisode exits 0 whether registration succeeded, skipped, or failed —
registration is fail-open by design, so the gate belongs to the caller. The tool
never runs git add, commit or push, never reports a later word than it
proved, and reports unperformed stages as owed, which is a state, not an
omission.
Development
This is a pnpm workspace (pnpm-workspace.yaml, packages/*). Never run
npm install here.
pnpm --filter @miadi/voice-mcp build # tsc → dist/
pnpm --filter @miadi/voice-mcp type-check
node dist/index.js # starts; every tool throwsVerified 2026-08-04, no installer run: tsc --noEmit exits 0, and emit produces
ESM with explicit .js specifiers that node dist/index.js resolves.
@modelcontextprotocol/sdk 1.30.0 and zod 4.4.3 are already present at the
workspace root and the declared ranges match them.
Why moduleResolution is bundler
@miadi/voice publishes raw TypeScript — main and types are both
./src/index.ts — and is authored with extensionless relative imports under its
own bundler resolution. Under NodeNext, every one of its internal imports is
a hard error in any consumer, so a package that wants its types meets it where it
is. This package's own source writes explicit .js specifiers, which bundler
accepts and tsc passes through unchanged, so the emitted dist runs under Node
ESM. Copying VoiceMessage into this package to sidestep that would create a
second definition, which is the one outcome worth more than a tsconfig line.
Known seams
- No installer has been run.
node_modules/@miadi/{voice,voice-client}here are the two workspace linkspnpm installwould create, added by hand so the package is verifiable today. Everything else resolves from the workspace root.@miadi/voice-clientmust be built (pnpm --filter @miadi/voice-client build) before this package'sdistcan run — it is imported at runtime, not only for types. - The ranked discovery order, the enforcement decision table and the two-corpus
resolution are still owed by the
voice-episode-bindingspec; seeSPEC-LINKS.md§ What the missing document blocks. The four working tools answer from what they are given and refuse to guess past it — which is why those rules stay cited rather than invented. - The
episodefield needs the server that accepts it. The binding is written byapp/api/voice/publish; a Miadi server built before that route change ignores the field and produces an unbound record. Same host, same request, silently different result — check the running build before reading a missing binding as a client bug.
Verified live 2026-08-04
Against the running miadi-server on 3335, MIADI_API_TOKEN_WRITER present:
ep308 resolved to its folder by number; a nonexistent folder answered null
with the five most recent episodes as alternatives; a cwd inside an episode
folder resolved by path; an empty call answered null and named the spec that
owes the ranked order; publish produced audio and returned a streamable URL plus
its on-disk path; voice_create_episode refused and named its contract.
🌸: A hundred voices have been speaking into a room with no name on the door — this package is the door, and the handle an agent can actually reach.
