Two sources are supported: config.yaml takes precedence, .env is the fallback. The recommended
pattern keeps secrets in .env and references them from config.yaml with ${VAR} (the loader
expands them), so config lives in one declarative file.
Most config.yaml keys have a matching TEAMS_CALL_* env var (e.g.
realtime.azure_endpoint ↔ TEAMS_CALL_AZURE_ENDPOINT); config.yaml wins where
both are set. Exceptions: path is config-only, and the SharePoint site id uses
TEAMS_SHAREPOINT_SITE_ID (shared with the chat plane).
config.yaml
%LOCALAPPDATA%\hermes\config.yaml (Windows) / ~/.hermes/config.yaml:
.env
%LOCALAPPDATA%\hermes\.env / ~/.hermes/.env - the secret store (used directly, or referenced above).
A fully env-only setup works too:
backend: openai uses public OpenAI instead of Azure - set the OpenAI key in place of the Azure
endpoint/deployment fields.
For the hosted StandIn bridge to connect, bind a reachable address: set host: 0.0.0.0 (or
keep 127.0.0.1 behind a tunnel that forwards to it) - the default loopback bind is only reachable
from the same machine. allow_remote_worker has no effect on inbound connections; it only
governs where outbound “call me back” requests may be sent (see the key reference below).
Key reference
Hermes implements the same feature set as the OpenClaw plugin; keys are snake_case and each has a
matching TEAMS_CALL_* env var.
Inbound policy (voice): Hermes vs OpenClaw
Hermes has no inboundPolicy key and no pairing mode for calls - the allowlist is the
inbound policy:
allowlist set → only those AAD object ids may call (add allowlist_allow_names: true to also
match display names, which is weaker).
allowlist empty → all inbound callers are denied, unless you explicitly opt out with
allow_all: true (env: TEAMS_CALL_ALLOW_ALL). An unset allowlist never means “open to all”.
- When
allowlist is empty, it inherits TEAMS_ALLOWED_USERS (the chat plane’s allowlist), so one
AAD list can cover both voice and chat.
The openclaw pairing approve msteams <CODE> steps you will see in the Quickstart and Teams app
pages are OpenClaw-only. The Hermes equivalents are: edit allowlist for voice callers, and
for Teams chat DMs (handled by the Hermes gateway’s Teams platform, not teams_call) either
approve the pairing code the bot sends an unknown sender (hermes pairing approve teams <CODE>) or
set TEAMS_ALLOWED_USERS - see
the bot ignores Teams chat.
Run
Show config + readiness:
Serve the bridge (use --handler streaming for streaming mode, which needs ffmpeg):
Or run it standalone:
Register your Agent voice URL (wss://<your-public-host>:8443/voice/msteams/stream via your tunnel; local bind ws://<host>:8443/...) and a matching shared
secret in your StandIn dashboard.
Microsoft Graph permissions
The bot’s Azure AD app needs admin-consented application permissions - see the canonical
Graph permissions table on the Create the Azure bot page.
The shared_secret must byte-match the secret set in StandIn, or the HMAC handshake fails and no
call connects.
Gateway-managed mode (two layers)
To have hermes gateway run host the voice bridge itself (one process, no
separate serve), enable both the plugin and the platform:
In this mode scheduled jobs can deliver by voice: a cron job with
deliver=teams_call places a call and speaks the result. Set
TEAMS_CALL_HOME_AAD to the AAD object id the gateway should call for those
deliveries (the callee must also be on the allowlist). Run gateway mode
or standalone serve, never both - the second owner fails loudly on the
port bind.
Voice & language tuning lives in Hermes
STT and TTS provider selection, voices, and language hints are host
configuration, not plugin keys: set Hermes’s own stt: and tts: blocks in
~/.hermes/config.yaml (see the Hermes TTS and voice-mode feature docs). The
plugin inherits them - streaming mode dispatches the host’s text_to_speech
tool and transcription chain, so all Hermes STT/TTS providers apply unchanged.
The call also answers with the host’s SOUL.md identity and knows the host’s
installed skills (chat/call parity).