Skip to content

infiquetra/voice

v0.3.0

Portable voice package: a spoken conversational loop for one explicitly bound, Herdr-managed Claude Code session. The bound session speaks its completed response, the operator answers by voice, and the transcript lands editable and unsubmitted in that same session's input box. The portable core carries the provider declaration contract, the stated settings surface, and the subprocess discipline; Claude-specific files live under the com.infiquetra.claude client extension directory.

Voice portable package

Portable Agent Plugins 1.0 package giving one explicitly bound, Herdr-managed Claude Code session a spoken conversational loop. The bound session speaks its completed response — after Markdown cleanup, with fenced code-block contents omitted — the operator toggles recording in the Voice pane and speaks, toggles again, a declared speech-to-text provider transcribes, and the text returns to that same session's input box, unsubmitted and editable. One session at a time, both directions, no arbitration anywhere. The package never installs, provisions, discovers, or substitutes a provider: providers are declared by the operator, preflighted, and reported.

The requirements behind this package are docs/brainstorms/2026-08-25-voice-plugin-requirements.md; the run-wide implementation plan is docs/plans/2026-08-25-voice-plugin-implementation-plan.md.

What is in the package

PathWhat it is
plugin.jsonAgent Plugins 1.0 manifest — the portable, vendor-neutral one
.claude-plugin/plugin.jsonClaude Code packaging manifest. A different specification, required by the Claude CLI to sit at the installed package root. Holds no behaviour: it declares the hooks and MCP server paths into com.infiquetra.claude/ and the portable skills directory
scripts/providers.pyProvider declaration contract: closed egress set, declarations, named refusals
scripts/settings.pyThe one settings reader: stated names, split defaults, absent never means empty
scripts/process.pySubprocess discipline: closed stdin and a deadline on every child
scripts/text_cleanup.pyMarkdown cleanup for the speak path: formatting syntax stripped, fenced code blocks omitted
scripts/speak.pySpeak path: synthesize and play one completed response through the declared voice-forge provider
scripts/record.pyCapture path: toggled recording tracked in state; nothing transcribed or kept without an explicit stop
scripts/transcribe.pyTranscription path through the declared Hermes relay: session token in process memory only, nothing kept
scripts/binding.pySticky single-agent binding store: one explicit bind, replaced only by an explicit rebind
scripts/deliver.pyDelivery path: transcript into the bound agent's input box unsubmitted, or a named audible refusal that holds it
scripts/preflight.pyPreflight the declared providers, the stop keybinding, and the executables; report by name, never install or repair
scripts/pane.pyThe Voice pane: the one long-running operator surface and listen-path sequencer
scripts/voice_cli.pyThe one command surface: pane, bind, preflight, toggle, stop, policy
scripts/bridge_client.pyBridge client for Auralis loopback HTTP bridge: discovery, presence, and rendering submission
scripts/adapter_identity.pyThree-part adapter identity resolver (agent_session_id, pane_id, terminal_id)
scripts/rendering_gate.pyPlain-spoken-text rendering gate: rejects Markdown/fences with named reasons (R121, R20)
scripts/voice_policy.pyVoice policy and preferences store: renders instructions, consumes Brief Next Turn override (R25, R107)
scripts/turn_record.pyCurrent turn record store: locked read-apply-write transactions (fcntl.flock) for turn state (KTD11)
scripts/mcp_server.pyModel Context Protocol (MCP) stdio server: submit_spoken_rendering tool and presence renewal loop (R21, KTD2, KTD8)
skills/voice/Agent Skill: documents the CLI and the in-pane keys; adds no second command surface
tests/unittest suites for the scripts above, the Stop hook, and the skill entrypoint
com.infiquetra.claude/Claude client extension directory: manifest, hook descriptor, MCP server declaration, and hooks

Claude-specific files — hooks and the client extension — never live in this portable core; they belong under the com.infiquetra.claude/ client extension directory.

Installing into Claude Code

The repository is a Claude marketplace. From a checkout:

claude plugin marketplace add /path/to/infiquetra-agent-plugins
claude plugin install voice@infiquetra-agent-plugins

Claude installs the package root — this directory — so the portable core travels with the client extension. That is required rather than tidy: the Stop hook imports the core and spawns scripts/speak.py from it, so an install carrying only com.infiquetra.claude/ would validate, then fail at the first spoken response. The reasoning and the rejected alternatives are recorded in docs/engineering-journal/DECISIONS.md.

Installing does not configure anything. Both providers are still declared by the operator, and voice preflight still has to pass before the loop runs — see Settings and The Herdr-wide stop keybinding.

Restart Claude Code after installing: the Stop hook is registered at session start.

Providers are declared, never discovered

Voice ships no provider implementations. Each provider is declared with its invocation or endpoint, its capabilities, its egress class, and the name of any credential environment variable it needs — never a value. Version one declares exactly two providers, built in code from the stated settings:

ProviderRoleEgress classCredential variable
voice-forgetext-to-speechlocal-networknone declared
hermes-xaispeech-to-textnamed-remote-servicenone declared

Neither provider declares a credential variable: the speech-to-text route's upstream credential is owned by the Hermes relay, and the relay's loopback session token is a transport detail held in process memory only — it is never stated, persisted, or logged.

The egress class is a stated value from a closed set of exactly four literals: on-device, local-network, named-remote-service, and unofficial-remote-endpoint. Anything else is rejected. "External" is not a fifth value: it is the predicate over the set that is true for named-remote-service and unofficial-remote-endpoint and false for the other two, which is the distinction Voice must draw between audio that leaves the machine and audio that stays on the local network.

A provider that is unavailable produces a named refusal carrying the provider name and the missing prerequisite. Voice never substitutes one provider for another, and never falls back.

Settings

All configuration is stated through the environment and read by scripts/settings.py alone. Absent is never treated as empty: a setting that is present but empty is refused by name rather than silently defaulted. No setting carries a secret; every value below is non-sensitive.

SettingDefaultMeaning
VOICE_FORGE_BASE_URLnone — refused by name when unsetBase URL of the Voice Forge text-to-speech service, from the operator's deployment
VOICE_FORGE_VOICE_IDnone — refused by name when unsetVoice the synthesis uses
VOICE_HERMES_BASE_URLhttp://127.0.0.1:8765Base URL of the Hermes relay
VOICE_HERMES_PROFILEmimir-engineerHermes profile the speech-to-text route resolves through
VOICE_CAPTURE_BIN/opt/homebrew/bin/ffmpegCapture executable, supplied by the operator, never discovered
VOICE_PLAYBACK_BIN/usr/bin/afplayPlayback executable, supplied by the operator, never discovered
VOICE_STATE_DIR~/.local/state/voiceMachine-local runtime state directory
VOICE_RETENTIONnone — must be statedRetention posture; version one accepts exactly ephemeral
HERDR_PANE_IDnone — refused by name when unsetCurrent Herdr pane identifier (w1:p1 or integer) for Auralis bridge adapter identity
HERDR_BIN_PATHnone — refused by name when unsetPath to Herdr executable for resolving session and terminal identity

Retention behaviour is a stated setting rather than a silent default: the empty case — audio deleted after success and failure, no transcript log, no telemetry — is something a person wrote down as VOICE_RETENTION=ephemeral. Any other value is refused by name rather than honoured.

The Herdr-wide stop keybinding

Voice preflights — and never writes — one operator-owned keybinding: a Herdr-wide binding whose command invokes the package's stop path.

First, put voice on PATH

Claude installs a plugin under a versioned directory, and a version bump creates a new one rather than reusing the old. There is no current or latest symlink, so no path inside an installed package is stable, and a keybinding written against one stops working at the next release without announcing it: the key still exists, the command still parses, nothing runs.

So the binding invokes a small launcher that resolves the current install at invocation time, from Claude's own plugin registry — which Claude rewrites on every install and update. Write it once:

python3 ~/.claude/plugins/cache/infiquetra-agent-plugins/voice/*/com.infiquetra.claude/scripts/install_launcher.py

That writes ~/.local/bin/voice and nothing else. It refuses to overwrite a voice it did not generate unless you pass --force, and --print shows the file without writing it. Confirm with command -v voice.

The launcher follows version bumps on its own; you never edit the keybinding again.

Then add the binding

[[keys.command]]
key = "<your key here>"
type = "shell"
command = "voice stop"
description = "stop voice playback"

The key is yours to choose. type = "shell" runs the command detached in the background, which is what a stop key wants — pane and popup would open a terminal to stop audio.

Voice reports this binding's absence by name; it never creates or repairs any Herdr configuration.

What preflight actually checks

Preflight resolves the configured command rather than pattern-matching it: it confirms the program is on PATH (or is an existing executable file) and that any .py script named beside it exists. It never runs the command — firing a stop as a side effect of asking whether a stop is possible would be its own defect.

This matters because containing the text voice stop and being able to stop anything are different claims, and they came apart here: the binding was documented before any voice existed on PATH. A probe that reported that as healthy would have been worse than no probe, because it retires your own suspicion.

Auralis bridge adapter

The package also provides the Claude Code adapter for the Auralis V1 conversational loop (capability slice C3), specified in docs/plans/2026-08-27-auralis-c3-adapter.md. Auralis Core (repository infiquetra/auralis) exposes a loopback HTTP bridge governed by the five-route Auralis Bridge Contract v1 (GET /v1/health, PUT /v1/presence, DELETE /v1/presence, GET /v1/current, POST /v1/rendering).

Adapter architecture and lifecycle

  1. Long-lived MCP process (scripts/mcp_server.py): The stdio Model Context Protocol (MCP) server declared in com.infiquetra.claude/mcp/servers.json persists for the Claude session. It runs a background presence thread that registers and renews presence (PUT /v1/presence) on the contract lease cadence and sends DELETE /v1/presence on clean shutdown (KTD2). It exposes the submit_spoken_rendering tool through which the agent submits its authored spoken rendering.
  2. Plain-spoken-text rendering gate (scripts/rendering_gate.py): Submissions are locally validated before reaching the wire (KTD1; R121, R20). Markdown formatting or fenced code blocks are rejected with named reasons (fenced_code_block, markdown_formatting) and never reformatted or cleaned. Accepted plain text is forwarded byte-identical to POST /v1/rendering using the prompt-time captured turn identifiers.
  3. Turn origin and policy injection (com.infiquetra.claude/hooks/user_prompt_submit_hook.py): At prompt submission, the hook queries GET /v1/current, matches adapter identity, captures (binding_id, turn_id) into scripts/turn_record.py, and injects explicit context telling the agent whether the turn originated via voice, that a spoken rendering is expected, and the operator's current voice policy instructions rendered by scripts/voice_policy.py (including any armed one-shot Brief Next Turn override, consumed on transmission; R106, R107).
  4. PreToolUse observation (com.infiquetra.claude/hooks/pre_tool_use_hook.py): Observes tool use on voice-originated turns and appends observations to the current turn record (KTD7). It is observe-only and never emits permission decisions.
  5. Completion reconciliation (com.infiquetra.claude/hooks/stop_hook.py): When the turn completes, the Stop hook reconciles the turn record: if wire-bound and an authored rendering was accepted, outcome is recorded as authored; if the voice turn completed without an accepted rendering, outcome is recorded as fallback (KTD6; R22, R23, R122). While wire-bound, the legacy local speak path is suppressed so Auralis retains sole speech custody.

Subprocess discipline

Every subprocess Voice starts runs with its standard input explicitly closed and a deadline attached. Bounded helper calls carry the caller's deadline; a fully detached child runs in its own session with closed streams and carries its deadlines internally, because its parent never waits for it. No subprocess is ever started through a shell.

Runtime floor

Standard-library Python at the repository floor python>=3.12, tested with unittest. HTTP, where later units need it, uses urllib.request; the package adds no third-party dependency.

Provenance posture

This package is authored in this repository. It carries no PROVENANCE.json and no port descriptor, because it has no upstream to pin and no source repository whose bytes must be tracked. That is stated here plainly rather than leaving the absence to carry the meaning. Version one also ships no CHANGELOG.md: the git history carries the record until the first external release.