Skip to main content
standin-sdk[hermes-agent] answers Microsoft Teams calls with your Hermes Agent agent. StandIn answers the Microsoft Teams call and dials your worker. This plugin answers that dial, connects a realtime speech-to-speech model for the conversation, and gives that model one door into Hermes, so the caller talks to the same assistant they know from chat, with their own tools, files and skills behind it.
Realtime speech-to-speech. The plugin drives a realtime model for the conversation, which is what keeps a call at conversational latency, so a realtime provider key is required alongside the StandIn secret. Scope has the whole boundary in one list.

Who is the brain

The realtime model is. It hears the caller and answers them, at conversational latency. Hermes is reached only when the model calls hermes_agent_consult, and it never sees audio. The model is good at conversation, Hermes is good at work. That split is the whole design.

Install

Into the Python environment that runs Hermes:
Hermes loads the plugin in-process, through the hermes_agent.plugins entry point. There is no HTTP hop to Hermes, no session header, and no second service to run.
The entry point key stays msteams_bridge, which is the name shown in hermes plugins list and used in plugins.enabled, so an operator upgrading from the standalone bridge keeps their config.yaml unchanged. The Hermes host is deliberately not a dependency of the package. Hermes is not on PyPI: it is the application the plugin is installed into. Everything that does not need the host works without it, and everything that does raises HermesUnavailable with a sentence saying so.

Setup

Then expose port 9442 at the /msteams/calling path and register the public wss:// URL as your StandIn identity’s agent voice URL. Expose your agent carries the mount command and the probes, and it is the only page that does: three spellings of one URL across three pages is what caused a live incident here. Call your StandIn number.

Check before you call

Answers “would a call work right now?” without placing one: it checks the secret, the realtime key and every Hermes surface the call needs, and names what is missing.
Answers the same question by placing one. It rings this worker’s own handler on loopback and exits non-zero if audio did not make the round trip, so CI fails on a bad install. See Checking the install. There are exactly three subcommands, status, smoke and serve, and the set is deliberate.

Running without Hermes

The same listener with no host. The call is answered and the model talks; hermes_agent_consult replies, in words, that it cannot reach its tools. Useful for checking a secret, a tunnel and a StandIn identity before standing up Hermes.

Configuration

The plugins.entries.msteams_bridge.config block, with a MSTEAMS_BRIDGE_* environment variable as the fallback for each key. Both are supported because both already exist in the field. The realtime: sub-block configures the provider:
Three things select Azure, not one. backend: azure is the explicit form, but setting azure_endpoint alone, or giving a url with azure.com anywhere in it, also switches the whole provider over: the key falls back to AZURE_OPENAI_API_KEY and then AZURE_FOUNDRY_API_KEY, the header becomes api-key instead of a bearer token, and azure_deployment becomes the model name. Leaving a stale azure_endpoint in a config block is therefore enough to send an OPENAI_API_KEY deployment down the Azure path, where that variable is never read. hermes msteams-bridge status does catch it, but it reports no realtime API key, which reads as a missing key rather than as the wrong backend.
Three more keys tune turn detection, which is what to reach for when the assistant interrupts too eagerly or waits too long before answering: The default instructions are deliberately about voice behaviour and not about who the assistant is: identity comes from the operator’s own Hermes configuration through the consult boundary, so the caller talks to the same assistant they know from chat. Override it and you are replacing the voice manner, not the personality. The listener itself belongs to the SDK: STANDIN_SECRET, STANDIN_PORT (9442), STANDIN_HOST (0.0.0.0), STANDIN_WS_PATH (/msteams/calling).
Turning input_transcribe_model off disables the group gate and the verbal interrupts as well, because both read the caller’s transcript. The plugin logs a warning when it starts a call that way.

What the model can call

Neither ever raises at the model. A consult that times out, and a tool name the model invented, both come back as a sentence it can recover from, because a tool result is fed straight into a live conversation and an exception there leaves the caller listening to nothing.

What Hermes itself gains

Enabling the plugin also registers one Hermes tool, msteams_bridge_status, in a msteams_bridge toolset. It takes no arguments and answers the same question as hermes msteams-bridge status, so you can ask your assistant in chat whether a call would work right now. It is registered when the plugin is enabled, not when the listener runs, so the answer is available in a Hermes process that is not serving calls at all.

In a meeting

The assistant stays silent until somebody says one of the wake phrases, then keeps answering for follow_up_window_ms without needing the name again. The machinery is the SDK’s own GroupGate, which Group calls documents for a handler you write yourself; the keys above are this plugin’s way of configuring it. Saying “stop” or “wait” as a whole utterance cuts playback in code, whether or not the model would have stopped. Phrases ship for four languages, English, Arabic, French and German, and the match is on the whole utterance rather than a substring, so “stop by the store” is a sentence rather than an interruption. Stop means stop has the set and the normalisation. The gate decides “is this a meeting?” from the thread id: Microsoft Teams gives a meeting or channel conversation a thread beginning 19:, and it is present in session.start on every call. The participant count is kept as a second, corroborating signal that can only add certainty, never remove it. That is a fix: the standalone bridge this plugin replaces decided from a participant count that does not arrive on the meeting-join path, so the assistant answered every turn of every meeting it was invited to. The goodbye was the other fix. StandIn sends a closing line when it is about to end a call, so it almost always arrives while the model is mid-answer. The old path injected it with a plain say, whose response creation is guarded on “is a response already active”, so the guard swallowed it and the caller heard nothing before the line went dead. This plugin cancels the response first, then speaks.

Scope

The conversation is realtime speech-to-speech. The provider is OpenAI or Azure OpenAI out of the box, and realtime.url points the same protocol at a socket of your own. A provider that offers only speech to text and text to speech is not one this plugin can drive: the whole design rests on the model hearing the caller directly, which is where the latency goes. The model’s tool surface here is the two above. hermes_agent_consult reaches the caller’s whole Hermes agent, so anything Hermes can do in chat it can do on the call through that one door. That is a boundary of this plugin, not of the call. The StandIn call wire carries vision, expression, visemes and display messages as well as audio, and this plugin already drives the expression and viseme half itself: see On the tile. What it does not do is put the SDK’s model-facing tools into the Hermes realtime session. Reach for VisionTools and CallTools yourself if your agent needs them:
  • looking at a screen share, and showing a picture, a page or a file: Vision and the avatar
  • ringing somebody back: Reaching people, where CALL_BACK_TOOL and CHAT_CALLBACK_TOOL ship from the standin barrel
  • writing up the meeting: Meeting recap, where MINUTES_TOOL does

On the tile

The caller sees StandIn’s avatar, and this plugin drives its face and its mouth without you doing anything. Two hints go out per turn:
  • Expression. An ExpressionCue reads the reply text as it is being produced and sends express when the emotion changes. It is re-read on every piece rather than once at the end, because waiting for the final transcript leaves the face wrong for the whole time the reply is being spoken. A tool call raises the thinking cue and lowers it in a finally, so a model that stays silent after a tool result does not leave the face mid-think for the rest of the call.
  • Visemes. A TurnLipSync measures the audio the turn actually sent and hands StandIn one mouth timeline per turn, through send_speech_marks, when the turn ends.
Both are cosmetic, so every failure in that path is swallowed: the worst acceptable outcome is a still mouth over correct audio, and the unacceptable one is a dropped turn because a lip shape raised. The avatar is the page for doing the same thing from a handler of your own.

Full example

examples/hermes-msteams-connector has the .env layout and a config.yaml.example block to merge into your existing Hermes configuration. There is no agent file in it, and that is the point: Hermes already is the agent.

Next

Call handler

The seam this plugin implements.

Audio

The 16 kHz wire against a 24 kHz realtime model.

Realtime providers

The startup buffer, the echo guard and barge-in this plugin sits on.

The avatar

The expression and viseme hints this plugin sends for you.