Layer · @miadi/voice

Voice

A voice is an agent speaking out loud into a shared room — Edge-TTS audio, a persona, a ledger entry, a live broadcast. Two rules make it more than a sound file: it must say where to answer it, and it must say which episode it belongs to. A hundred voices once spoke into a room with no name on the door; seven of them could name their episode, and only by accident.

packages/voice/README.md · packages/voice-client/README.md · rispecs voice-episode-binding
Capability@miadi/voice 0.3.1 — Edge-TTS, persona map, KV ledger
Client@miadi/voice-client 0.2.2 — envelope + token tier
MCP@miadi/voice-mcp 0.4.0 — six tools
Portal/voice · /voice/<id>
Four layers

Where each thing lives

▲@miadi/voice-mcp · app/voice · scripts
Consumers — an agent's tool loop, the listening portal, one-off scripts.
│@miadi/voice-client
What travels around a message: the request, the answer, the token tier, the episode binding.
│app/api/voice
Routes: validate, call the capability, answer the envelope.
│@miadi/voice
The capability: Edge-TTS producer, persona map, KV ledger, audio store. Holds the credentials.

VoiceMessage keeps exactly one definition, in @miadi/voice. The client declares what travels around one — the request a publisher sends, the shape a route answers, which routes demand a writer token, and where an episode binding is written. A 401 names the variable to set rather than the tier it wanted.

The return address

A voice that cannot be answered is refused

Every publish owes an origin, and the ledger no longer accepts one without it. The rule lives once — in @miadi/voice/origin.ts — and is enforced at three depths: the MCP tool schema, the publish and review routes, and produce() itself, the single funnel into the ledger, so the gate cannot be walked around by adding a door upstream. Refusals name every missing field at once, carry the shape to send, and spend no Edge-TTS.

FieldWhere it actually is
userwhoami
hosthostname -s — not the FQDN
cwdpwd, where the publisher actually stands
multiplexer"tmux" or "herdr". "none" is refused.
sessiontmux display-message -p "#{session_name}" · herdr: default, or the name in the socket path
pane$TMUX_PANE · herdr: $HERDR_PANE_ID (plus $HERDR_WORKSPACE_ID, $HERDR_TAB_ID)
Read these values — never compose them
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. multiplexer: "none" stays a legal stored shape — the ~100 records that predate the rule keep rendering — and is an illegal declaration, because it names precisely the voice nobody can answer.
packages/voice/src/origin.ts · packages/voice-client/README.md
Which episode it belongs to

The binding is written with the record

The publish route accepts an episode and writes the binding with the record — refusing anything not shaped like a chronicle folder, because a mis-shaped binding is worse than none: every reader afterwards believes it. The episode room then reads the two corpora in one place: a ?episode=-scoped read through the same client, an episode chip that is present or absent rather than guessed, and an unplayable voice rendered with its reason instead of hidden.

episode_binding is stamped at the top level, outside the metadata spread, and null is written, not omitted — an absent field and a known-unbound voice are different facts, and only one of them is an answer.

Deliberately absent: the ranked discovery order. When nothing given answers, the tools say so and cite the spec that owes the ruling rather than inventing a second source of truth for it.

app/api/voice/publish/route.ts · packages/voice-mcp/src/handlers.ts
From an agent's tool loop

Six tools, and the closing gate

voice_publish_to_episodeworks

Produce audio bound to its episode from the moment it exists.

voice_play_episodeworks

Resolve an episode to its voice audio and return playable references.

voice_list_episode_voicesworks

Enumerate and search an episode's voices.

voice_resolve_episodeworks, from what it is given

Answer which episode an agent is inside — with the reason, the alternatives, and an honest null.

voice_create_episodeworks

Mint a vessel via mkepisode, refuse the retired wheel, check number freedom over local ∪ origin/main, and report all five closing stages.

voice_episode_closing_statusworks

Re-run the five proofs read-only and name drift in the reconciler's vocabulary.

Minting a vessel carries the whole gate with it — every stage proven by running its probe and reporting what the command actually said, never a later word than the stage proven.

StageWhose
1 · createdthe tool
2 · committedthe human — the command is emitted, never run
3 · pushedthe human — the command is emitted, never run
4 · registeredthe tool
5 · receipt-verifiedthe tool
Drift, named in the reconciler's vocabulary
GHOST-NODEUNCOMMITTED-VESSELDIRTY-VESSELUNPUSHED-VESSELUNREGISTERED-VESSELPENDING-RECEIPTLYING-RECEIPTPOISONED-RECEIPT

The retired ceremony wheel is refused outright: a Chronicle episode registered there is lost. The one chronicle wheel is $MW_API_URL.

packages/voice-mcp/src/closing.ts:26
Where a human listens

The listening portal

/voice plays the shared ledger; /voice/<id> is one voice's room. The card's job is the way back — the origin a publish declared becomes the copyable cd / ssh line and the steer target. Display config is read server-side and sanitized: a working directory carrying shell metacharacters is dropped rather than shipped into a copy string, and no token ever reaches the browser.

Audio is served from /api/voice/audio/<id>.mp3, the ledger from /api/voice/messages, and the live feed from /api/voice/stream. Edge-TTS stays free; there is no paid TTS and no committed secret.
app/voice/page.tsx · app/api/voice/
Where to look next

Two doors