Skip to main content
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_endpointTEAMS_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).