Realtime voice gateway plugin for hermes-agent.
It serves an OpenAI-compatible direct WebRTC endpoint and can join a LiveKit room as a Realtime Conference agent. Both transports transcribe speech through Hermes, run the same agent loop, return TTS audio, and share one conversation event contract. The name is now historically accurate in only one direction; the rename raccoon remains on the roadmap.
The gateway mark combines a transport arch, messenger wings, and an audio
pulse. docs/assets/hermes-gateway-master.png is the opaque lossless source;
./scripts/export-readme-mark.sh regenerates the optimized README image. The
original artwork was generated with OpenAI's built-in image generation tool on
2026-08-27 with text, third-party marks, and transparency prohibited.
- An existing
hermes-agentinstall (this plugin attaches to it; it does not vendor hermes itself). ffmpegonPATH— used to decode TTS audio for the WebRTC publish path.- macOS:
brew install ffmpeg - Debian / Ubuntu:
sudo apt install ffmpeg
- macOS:
- A reachable LiveKit server and API key/secret pair only when using the Conference transport. Direct WebRTC has no LiveKit dependency at runtime.
Install into the same Python environment as your hermes-agent:
python -m pip install git+https://github.com/kortexa-ai/hermes-livekit.gitpip resolves the pinned livekit / livekit-api SDK versions automatically.
If Hermes' environment does not contain pip, target its interpreter with uv:
uv pip install --python /path/to/hermes/python \
git+https://github.com/kortexa-ai/hermes-livekit.gitThe plugin is auto-discovered through the hermes_agent.plugins entry-point
group — no edits to hermes-agent's source tree are required. This revision
requires Hermes Agent 0.20.0 or newer because that host line carries the
Pillow 12.3 security fixes and the current platform-registration contract.
Until 0.20.0 is published, install against a current Hermes source checkout.
Note:
hermes plugins install kortexa-ai/hermes-livekitis not the right path for this plugin. That commandgit clones into~/.hermes/plugins/without resolving pip deps; you'd then have topip install 'livekit==1.1.14' 'livekit-api==1.2.0'by hand. The pip install above is one command and keeps the SDK pins in sync with the plugin version.
For development on a checkout (e.g. ~/src/hermes-livekit/):
python -m pip install -e ~/src/hermes-livekitRun that command with the Python interpreter from the same environment as
Hermes. Re-run it after pulling a change to pyproject.toml: editable installs
reflect Python source edits immediately, but package metadata such as the
version, dependencies, and plugin entry point is generated at install time.
The uv equivalent is:
uv pip install --python /path/to/hermes/python -e ~/src/hermes-livekitAfter install, add livekit to the enabled-plugins list:
hermes plugins enable livekit(Or edit ~/.hermes/config.yaml and add livekit to plugins.enabled.)
Then enable either or both platforms in the same config:
platforms:
livekit:
enabled: true
realtime:
enabled: true
plugins:
enabled:
- livekitThe direct adapter implements OpenAI-style SDP signalling at
POST /v1/realtime/calls (with /realtime/calls as an alias), RTP audio, and
the oai-events data channel. The preferred setup request is multipart with a
required sdp field and optional session field. It returns 201 application/sdp with the call resource in Location; raw application/sdp
offers remain accepted for older clients. Every listener requires a Bearer
token:
platforms:
realtime:
enabled: true
host: 127.0.0.1
port: 8091
api_key: ${HERMES_REALTIME_API_KEY}The equivalent environment-only setup is:
HERMES_REALTIME_ENABLED=true
HERMES_REALTIME_HOST=127.0.0.1
HERMES_REALTIME_PORT=8091
HERMES_REALTIME_API_KEY=choose-a-long-random-token
HERMES_REALTIME_ALLOW_ALL_USERS=trueSend HERMES_REALTIME_API_KEY as a Bearer token. Startup fails closed if it is
missing. HERMES_REALTIME_MAX_CALLS defaults to 8 and
HERMES_REALTIME_MAX_CALL_SECONDS defaults to 7200. Offer upload and ICE
negotiation share a 30-second setup deadline; unsuccessful setup releases its
call slot, peer, and tools. The current direct edge
advertises host ICE candidates; deployments across NAT still need a TURN-aware
front door before they are internet-ready.
Each listener is permanently bound to the Hermes profile of its gateway
process. Trusted routers can discover that fixed binding from
GET /v1/realtime/discovery with the same Bearer token:
curl -H "Authorization: Bearer $HERMES_REALTIME_API_KEY" \
http://127.0.0.1:8091/v1/realtime/discoveryThe bounded response contains only version, profile, and the relative
realtime_path. It does not enumerate other profiles or expose credentials,
filesystem paths, rooms, models, or provider configuration.
Set these env vars. Current Hermes also accepts the matching lowercase keys
either directly under platforms.livekit or inside platforms.livekit.extra:
platforms:
livekit:
enabled: true
url: wss://your-project.livekit.cloud
api_key: your-project-key
api_secret: your-project-secret
room: hermes| Var | Required | Notes |
|---|---|---|
LIVEKIT_URL |
yes | wss://your-project.livekit.cloud or wss://your-self-hosted/ |
LIVEKIT_API_KEY |
yes | from your LiveKit project / server config |
LIVEKIT_API_SECRET |
yes | from your LiveKit project / server config |
LIVEKIT_ROOM |
no | room the agent joins; default hermes |
LIVEKIT_AGENT_NAME |
no | display name; default Hermes (asks the LLM if unset) |
LIVEKIT_AGENT_AVATAR |
no | avatar URL or local image path (encoded as data URI) |
LIVEKIT_HOME_CHANNEL |
no | cron / cross-platform delivery target; defaults to LIVEKIT_ROOM |
LIVEKIT_ALLOWED_USERS |
no | comma-separated participant identities |
LIVEKIT_ALLOW_ALL_USERS |
no | 1/true allows any participant (dev only) |
LIVEKIT_PRESENCE_POLL_INTERVAL |
no | seconds; auto-picked (cloud 30s, local 5s) |
HERMES_LIVEKIT_TOOL_TIMEOUT_SEC |
no | native RPC response timeout; default 30 seconds |
HERMES_LIVEKIT_REMOTE_TOOL_POLICY |
no | bounded JSON policy; absent or invalid denies all remote tools |
Or run the interactive prompt:
hermes configSet silence_duration in each platform's configuration, in seconds:
platforms:
realtime:
extra:
silence_duration: 0.7
livekit:
extra:
silence_duration: 0.7The default remains 1.5 seconds. Values from 0.2 to 5.0 are accepted; invalid
values fail configuration instead of silently changing voice behavior. Restart
the affected profile's gateway after changing it. Hermes CLI's
voice.silence_duration is a separate recorder setting and does not affect
these transports. Direct WebRTC checks on received audio frames; LiveKit checks
every 200 ms, so it can add up to one polling interval.
The energy gate calibrates from the quieter half of the initial 400 ms, then tracks background noise continuously during idle audio and confident quiet pauses. It has separate speech-start and speech-stop thresholds. Noise rises slowly and falls faster, using audio duration rather than frame count. Detected speech and near-threshold phonemes during a turn are excluded from adaptation. Mute/unmute recalibrates the gate against the real microphone.
This is still energy-based detection, not semantic turn detection. A loud new noise above the speech threshold can look like speech; a shorter timeout can split thinking pauses. Validate with the actual room and microphone before lowering it further. Endpoint logs include observed silence, configured target, and learned noise RMS; they do not contain microphone recordings.
For speech/noise discrimination, optionally install the vad extra in the
gateway's Python environment and prepare the pinned Silero v6.2 model:
python -m pip install -e '.[vad]'
python tools/prepare_vad.py /absolute/path/to/silero-v6.2.onnxSet these keys under either platform's extra configuration, then restart
only the affected gateway:
vad_backend: silero
vad_model_path: /absolute/path/to/silero-v6.2.onnx
vad_threshold: 0.5The default backend remains rms, with no added runtime dependency. Silero
uses only CPU, one inference thread and independent state per input. The
model is shared between inputs, loaded at adapter startup and checked against
a pinned SHA-256. Missing dependencies or an invalid model fail startup; there
is no automatic download, GPU fallback or silent change back to energy VAD.
The confidence threshold accepts 0.2–0.9; lower values are more sensitive.
The existing silence timeout still applies: this is not semantic endpointing.
The timer starts at the detector's last positive decision, which can occur
after audible speech has ended. Equal timer values do not guarantee equal
endpoint latency across backends. Measure total endpoint delay and test
mid-sentence pauses before lowering the timer; a shorter timer can split turns.
The model and its license
are MIT-licensed by the Silero Team; the installer saves the license beside
the model. Keep both outside the repository.
Both backends retain 500 ms of pre-roll to protect word onsets, without waiting
longer to dispatch the utterance. Continuous
capture is limited to two minutes per input. Overflow discards the whole
unsubmitted utterance and reports input_audio_too_long; no truncated command
is sent to ASR or Hermes. Pause for the configured silence interval, or mute
then unmute, before trying again. Other callers and ongoing replies continue.
For an offline noise-step and quiet-speech check through the actual direct capture path, run:
python tools/vad_probe.py --model /absolute/path/to/silero-v6.2.onnx \
--speech-fixture /absolute/path/to/mono-pcm16.wavIt reports CPU timings and endpoint events without recording, playback or service calls. Synthetic fixtures do not replace qualification with the actual room/mic.
To overlap transcription with that silence window, optionally set
asr_prefetch_silence: 0.35 beside silence_duration: 0.7 in either platform's
extra configuration. The default is 0 (disabled); enabled values must be
at least 0.1 seconds and below silence_duration. Restart the affected gateway.
This uses the existing Hermes STT provider, model and prompt. It is speculative
batch ASR, not a persistent streaming-ASR connection.
After a qualifying quiet pause, one early request runs in the background. The gateway still waits for its normal endpoint before dispatching a user turn. It reuses the early result only if speech has not resumed and the final input differs solely by quiet PCM at the tail; otherwise it transcribes the final audio normally. Empty or failed early results also fall back. Each input has at most one speculative worker in flight; invalidating a candidate does not cancel an active server request. A pause followed by more speech can therefore cost an extra ASR request. This is an energy-based safety check, not proof that very quiet speech was absent. Validate accuracy and load with the real mic. Gateway logs indicate whether each final transcription reused an early result.
Direct WebRTC keeps a paced 20 ms audio stream open, including silent frames between replies and between generated clauses. This keeps the receiver's media clock running instead of restarting an audio burst after every pause. Idle frames do not create response events or delay speech-queue draining; they add a small ongoing Opus/network cost while a call is connected.
Both transports implement Hermes Agent's streaming-TTS audio sink. For voice turns with auto-TTS enabled and a supported streaming provider, completed text clauses can play while the rest of the reply is still being generated. The configured provider and voice are preserved; no TTS-server or client changes are needed. Unsupported providers retain whole-file playback.
PCM is resampled incrementally to 48 kHz mono and published in paced 20 ms frames. Direct WebRTC queues at most 500 ms of audio ahead of the sender; LiveKit uses its audio source's playout backpressure. A failure before playable audio permits whole-file fallback. After playback starts, failure or cancellation must not replay the reply from the beginning.
Streaming requires a Hermes Agent consumer that preserves adapter-owned
handle.audible and supports tts.streaming.completion_timeout (default 120
seconds, accepted range 1–600). This bounds the wait for a long reply to finish;
it does not delay the start of playback. The companion fixes are tracked in
the streaming integration issue.
Generated PCM can itself start with a long near-silent prefix. To shorten that
pause, set platforms.realtime.extra.tts_trim_leading_silence: true (or the
equivalent platforms.livekit.extra key) and restart that profile's gateway.
This boolean defaults to false. It removes only near-silent frames at the
start of each streamed reply, retaining 80 ms before the first sample above
PCM16 peak 16 (about -66 dBFS). It scans at most one second and buffers at most
four 20 ms frames. Internal pauses, later clauses, and retained samples are
unchanged. This is conservative amplitude trimming, not speech recognition;
disable it when exact leading timing or unusually faint output must be kept.
The provider's output contract and whole-file fallback are unchanged.
Run the transport tests with uv run --no-sync pytest -q tests/test_streaming_tts.py.
To test the configured TTS service through real local RTP peers, explicitly opt
in with HERMES_TTS_CANARY_CONFIG=/path/to/profile/config.yaml and select
-k smarty-opt-in -s. The canary prints timings, not credentials, and does not
play sound on a physical device.
For an end-to-end measurement from the Pi, run the isolated voice probe:
uv run --no-project --no-build \
--with aiortc==1.15.0 --with aiohttp==3.14.3 --with av==17.0.0 \
python tools/voice_latency_probe.py \
--gateway http://192.168.2.6:8092 --config-host snappy --profile mira --case greetingIt fetches profile credentials over passwordless SSH into memory, synthesizes
one fixed question, and sends a real voice turn through ASR, Hermes, and TTS.
Choose --case greeting, fact, calculation, or first_sentence. The session requests no tools,
but that does not disable Hermes's own tools: inspect gateway API counts before
treating any case as a single model request.
The probe rejects split or premature endpoint detections instead of reporting
latency for mixed turns. Keep failed samples when evaluating endpoint quality;
do not treat them as valid latency results.
To compare reasoning effort on the same model, add --reasoning medium or
--reasoning low. The probe sends Hermes's native session-only /reasoning
command in its own new call and waits for an English acknowledgement before
speaking. It never sends --global, changes the kiosk call, or changes the model
or service tier. Command errors, unexpected audio and missing acknowledgements
fail the probe. The default --reasoning profile sends no command. Add
--show-answer to include at most 500 characters of the fixture answer for
manual review; otherwise only its word count is reported. Counterbalance effort
order and compare several serial runs. These simple questions do not establish
general reasoning/tool quality, so do not promote a default from timing alone.
Use --turns 4 to compare the first reply with three later replies on the same
connection (1–10 turns; default 1). The probe synthesizes the question once and
replays identical PCM without resetting the RTP clock. Multi-turn output has a
turns array, one shared call_id and fixture_sha256, and a response_id per
reply. There is a one-second settling interval between completed replies and
the next utterance; recent residual audio, early response audio, split fixtures
and multiple audio replies invalidate the measurement. The interval is outside
the measured latency. Correlate these identities with the opt-in gateway metrics
to distinguish fresh-call prompt-cache misses from warm-session model time.
Repeated requests share conversation history, so later answers can differ;
this measures the conversation path, not identical model inputs or pure prefill.
Native transcript frames add caption_after_speech_s, caption_to_audio_event_s
and caption_to_audible_rtp_s. These use the first nonempty incremental frame
or full-snapshot replacement for the answer's response ID. Empty frames,
final-only setup notices and other responses do not start this clock. Older
transports without these frames omit the fields. These are client receipt times,
not the provider's first-token timestamps; a negative caption-to-audio interval
means that audio started before the caption arrived.
The first_sentence case requests a fixed two-sentence answer starting with
“Yes.” and rejects a different final answer. Use it to measure the delay from
buffering a short opening sentence into the next one. Its instructions belong
only to the isolated test call; it does not change Mira's profile or speech
chunking settings. Timing and text preservation do not establish voice quality.
It neither captures the microphone nor plays audio. The configuration host
must have Hermes at /Users/francip/src/hermes-agent with its existing venv;
the speech fixture expects the configured service's 24 kHz mono PCM format.
Run probes serially and avoid concurrent service benchmarks. JSON timings
measure from the last voiced source frame to endpoint detection, transcript,
audio-start event, and first audible RTP received by the Pi. The latter includes
network and receiver buffering, but not the kiosk's physical speaker latency.
Compare several runs: model generation time can vary independently of the
silence timeout or TTS transport.
For tool continuations, use tools/tool_latency_probe.py --input voice --case dependent.
This sends a synthetic spoken request through ASR and the streaming-TTS voice
path. Two side-effect-free client functions return fresh values; the second
requires the first result as an integer argument. The probe rejects missing,
out-of-order or incorrect arguments, incomplete utterances and unmatched audio.
Other cases cover independent tools (parallel), partial failure (mixed),
the real server deadline (timeout), and cancellation (cancel). The default
--input text retains the typed-input canary and its whole-file TTS path.
Timings explicitly identify speech_end or text_submitted as their reference.
An optional --model gpt-5.6-terra (also Astra or Sol) sends a native
/model ... --provider openai-codex --session command and requires confirmation
of the exact model, provider and session-only scope before the test starts.
The default --model profile sends no override. Profile reasoning is unchanged.
For matched comparisons, the Python probe function accepts the same in-memory
48 kHz mono int16 fixture across calls; compare the returned SHA-256 hashes,
counterbalance model order, and verify server-side model IDs and tool rounds.
Do not infer general agent quality from these bounded tool fixtures or change
the production model based only on faster canned answers.
For local model timing logs, set
plugins.entries.livekit.settings.voice_latency_metrics: true in the Hermes
profile and restart its gateway. It defaults to off and uses existing plugin
hooks; it does not rewrite requests or change model configuration. Hermes
treats stream observers as stream consumers, so enabling this in a mixed-channel
profile can also enable internal model streaming for other channels. Logs remain
limited to voice turns. voice_timing JSON logs contain the
first observed visible text and each completed API request, token/cache counts,
and tool-call count. Correlate (session_id, turn_id, iteration); separate hook
workers can log out of order. First-text time includes observer queue delay.
Provider first-chunk time is null when unavailable (including current Codex
Responses); neither metric isolates provider queueing, network or model prefill.
Only timings and bounded identifiers are logged; no text, audio or credentials
are retained by this observer. Deduplication holds at most 1,024 request keys.
Existing audio logs distinguish first provider PCM entering the sink from the
first published PCM frame, so resampling/onset gating is measured separately.
Use the same isolated dependencies with python tools/tts_onset_probe.py to
measure provider first bytes versus audible PCM, then compare the identical
waveform with and without onset trimming. Its sample-offset difference is a
PCM-domain saving, not an end-to-end playback measurement. It also reports
filter CPU cost on the machine running the probe and verifies that every
retained sample is unchanged.
hermes gateway restart
hermes gateway status # should show 🎙️ LiveKit as connectedFor direct WebRTC, status also shows ⚡ Realtime and the signalling endpoint
is http://127.0.0.1:8091/v1/realtime/calls with the defaults above.
Join the configured room from any LiveKit client (web, mobile, voice-agent desktop). The agent watches the room when empty and joins as soon as a real participant arrives, then transcribes incoming audio and replies via TTS.
Reliable JSON messages on conference.events use the same OpenAI-compatible
session and conversation contract as api.server Conference calls. Audio
stays on LiveKit tracks. Every participant in the room shares one Hermes
conversation session; participant identity still scopes errors and tools.
The agent sends a targeted session.created snapshot when each participant
joins. Room lifecycle events are broadcast:
input_audio_buffer.speech_started/speech_stoppedconversation.item.added/conversation.item.doneconversation.item.input_audio_transcription.completedresponse.created/response.doneresponse.output_item.added/response.output_item.doneresponse.content_part.added/response.content_part.doneresponse.output_audio_transcript.delta/.doneoutput_audio_buffer.started/stopped/cleared- correlated
errorevents
When Hermes text streaming is enabled, captions arrive during generation,
independently of audio. Append delta to the item identified by item_id;
if a delta event includes the optional transcript snapshot, replace that
item's draft instead (native text can be revised at a tool boundary). The
final .done transcript replaces the draft, not a second message. Neither
text update nor text completion indicates that audio playback has stopped.
The adapter capability does not override a profile's disabled text-streaming policy. Enable only the voice platforms when other clients should stay final-only:
hermes --profile mira config set display.platforms.realtime.streaming true
hermes --profile mira config set display.platforms.livekit.streaming trueRestart that profile's gateway after changing its configuration. The top-level
streaming.enabled can remain false; display.streaming controls the CLI,
not gateway delivery. A consumer created for interim messages alone does not
receive model text deltas. Qualify a real voice turn, not just adapter methods.
Clients send supported Realtime events on the same topic. Typed input uses a normal user conversation item:
{
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Hello"}]
}
}response.create starts the pending typed turn or requests a new response
through the normal Hermes message pipeline. response.cancel cancels the
active Hermes room turn and clears queued output audio. Unsupported events
receive an explicit error targeted to the sending participant. The old
hermes-chat and raw conversation agent:* streams are not part of the new
contract.
Hermes adds one namespaced input-state extension because the portable OpenAI Realtime event set has no microphone mute/unmute signal:
{"type":"hermes.input_audio.state","muted":true}Direct WebRTC clients send it on oai-events. Conference clients send the
same envelope reliably on conference.extensions. The server responds on the
same transport with hermes.input_audio.state_updated. Muting immediately
finalizes any active utterance and then ignores media frames; unmuting clears
stale audio and recalibrates the participant's adaptive noise gate. Clients
that omit this optional extension retain the configured server-side endpointing.
Portable tool discovery uses conference.tools, exactly as it does with
api.server Conference. Native RPC invocation and bounded byte streams are
negotiated LiveKit extensions. Triggered camera and runtime controls use the
separate conference.extensions topic.
Frame status messages use the extension topic:
agent:frame-capturedagent:frame-capture-failed
Portable registration acknowledgements on conference.tools are targeted to
the owning participant:
conference.tools.registeredconference.tools.rejected
Binary-result lifecycle messages are also targeted on conference.tools:
agent:tool-result-stream-ready— the targeted binary-result receiver is installed;{stream_id, topic}agent:tool-result-stream-cancel— stop and close that targeted binary stream;{stream_id, topic}
Reliable JSON payloads on conference.extensions:
end-of-turn is for clients that endpoint locally. Without it the adapter can
only notice you stopped once it has seen SILENCE_THRESHOLD_SECONDS of silence,
and that wait lands on every single reply. Unlike pause/resume — which are
global — this is scoped to the sending participant, so one client ending its
turn cannot affect anyone else in the room.
Tool catalogs use the portable Conference envelope on conference.tools:
{
"type": "conference.tools.register",
"tools": [{
"type": "function",
"function": {
"name": "desktop_notify",
"description": "Show a desktop notification.",
"parameters": {
"type": "object",
"properties": {"title": {"type": "string"}, "body": {"type": "string"}},
"required": ["title", "body"]
}
}
}]
}
// replace this participant's catalog with no tools
{"type": "conference.tools.register", "tools": []}Remote tools are disabled unless HERMES_LIVEKIT_REMOTE_TOOL_POLICY contains
an exact participant/name entry. Tier 1 permits the exact entry. Tier 2 also
requires a future Unix consent_expires_at. Tier 3 is always denied:
{"tools":[{"participant_identity":"desktop-client","tool_name":"desktop_notify","tier":2,"consent_expires_at":1798761600}]}The policy is loaded once when the adapter is constructed. See
docs/remote-tools-design.md for the closed
classification, bounds, and audit contract.
Advertised RPC method names use case-sensitive ASCII identifier segments
separated by single dots, with a 64-character total limit. For example,
camera.snapshot is valid; .camera, camera., camera..snapshot, path
separators, whitespace, Unicode, and normalized aliases are not.
Before advertising a tool, the client registers a LiveKit RPC method with the
same name. The agent calls that method with the tool arguments encoded as a
JSON object. The method returns either a JSON-shaped result or the bounded
byte-stream reference documented below, encoded as a JSON string, or raises
RpcError. LiveKit owns request correlation, response timeout, and error
transport. These calls do not use custom agent:tool-call or
client:tool-result data messages.
examples/test_client.py registers both desktop_notify and
camera.snapshot. The camera tool deliberately returns a built-in 1x1 PNG, so
the complete reference → targeted ready → byte stream → cleanup contract can be
tested without camera hardware. A real client can replace those fixture bytes
after applying the same 12 MiB bound. The stream header, chunks, and reference
target only the agent participant whose RPC invocation requested the snapshot.
For tools to be visible to the LLM, add hermes-livekit-tools to the
livekit toolset list in ~/.hermes/config.yaml
(platform_toolsets.livekit). The plugin does not auto-activate the
toolset.
Tools are removed automatically when the registering participant disconnects.
Full JSON, bounded-binary, and participant-scoped multi-client design in
docs/remote-tools-design.md.
Unknown type values are ignored silently — keeps the topic compatible
with apps that share the same data channel for unrelated control traffic.
The LiveKit SDK receives and decodes a subscribed camera continuously. The
adapter keeps only the newest frame, so idle video does not accumulate an
unbounded queue or make later snapshots stale. Send
{"type": "conference.capture_frame"} on conference.extensions and the
agent samples the latest available frame (or waits for one), encodes
it as JPEG (quality 85), and queues it locally.
The frame attaches to the next user message dispatched by the adapter
(either a closed voice utterance or a standard typed Realtime turn). The Hermes agent
loop then processes it through its existing image_input_mode: auto
vision path — exactly the same code path used by image attachments on
other platforms.
Frames captured but never claimed by a message are cleaned up on
disconnect. Frames attached to a message stay on disk through the agent
turn (the agent loop is fire-and-forget after handle_message).
For source-review findings and deferred verification, see
docs/code-review.md.
Experimental. Carved out of the kortexa/gateway-livekit branch on the
kortexa-ai/hermes-agent fork
(PR NousResearch/hermes-agent#3894)
so it can be installed on top of upstream main without patching core.
