# Architecture Source: https://docs.komaa.com/concepts/architecture What the StandIn service owns, what the SDK owns, and what your plugin owns: the handler seam, the session, the two lanes, and the wire facts. Joining a Microsoft Teams call means speaking Microsoft's own real-time media protocol. **You do not have to.** It is StandIn, a hosted service at [standin.komaa.com](https://standin.komaa.com). You run a worker, and the SDK is the other end of the socket StandIn dials. ```mermaid theme={null} flowchart LR T["Microsoft Teams
call or meeting"] <-->|"Microsoft Teams media"| S["StandIn
hosted service"] S <-->|"one authenticated WebSocket per call"| W["Your worker
StandIn SDK"] W <--> P["Your plugin
or handler"] P <--> A["Your agent
and voice provider"] ``` ## Three responsibilities, three owners | Owner | Responsibility | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **StandIn** (hosted) | Handles the Microsoft Teams side. Dials your worker once per call. Places the reply audio back into the call. | | **The SDK** (`CallServer`) | The socket StandIn dials, the HMAC handshake and its single-use replay guard, capacity, draining, the wire protocol, outbound sequence numbers and the audio timeline, five bounds on a call that nobody closes, and idempotent teardown. | | **Your plugin** | What to do with the caller's voice, and where the reply comes from. The recognizer, the model and the voice are yours; everything between them and the wire is already in the SDK. | That split is why a plugin is small. The echo plugin answers a real Microsoft Teams call in under a hundred lines, and it is not a toy: it is the same shape every plugin keeps. ## The seam A handler implements as many of **seven callbacks** as it needs. All of them are optional, a missing one is a no-op, and nothing inherits from anything. The names are `snake_case` in Python and `camelCase` in TypeScript and are otherwise identical. | Method | When the server calls it | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `on_start(session)` / `onStart(session)` | The call is live. Join a room, open a realtime socket, build an agent. Audio does not flow until this returns, so a slow start delays the caller rather than dropping frames. | | `on_caller_audio(pcm)` / `onCallerAudio(pcm)` | One frame of the caller's voice. On the receive path of a live call, so it must not block. Truncated or malformed payloads never reach here. | | `on_context(text)` / `onContext(text)` | Non-interrupting context as a plain sentence ready to put in front of a model: participant counts and group etiquette, DTMF key presses, recording status changes. | | `on_video_frame(frame)` / `onVideoFrame(frame)` | One sampled frame of the caller's camera or screen share. Sparse and best-effort, not a video stream. Most plugins want `latest_video_frame()` instead and let the server keep the newest frame for them. | | `on_speaker_change(name)` / `onSpeakerChange(name)` | A different person started speaking. Only ever called when StandIn sends unmixed audio, and on change only: the name rides every inbound audio frame, so at 20 ms a frame a per-frame callback would tell a model who is talking fifty times a second and it would hear nothing else. | | `on_goodbye(text)` / `onGoodbye(text)` | StandIn is ending the call and wants this line spoken first. Interrupt the current turn and say it: a goodbye queued behind a long answer is one the caller never hears. | | `aclose(reason)` | Release everything this call holds. Called exactly once, on every path including cancellation, before the slot is freed. | An exception raised from any of them is logged and ends that call alone. One bad call must never take the worker with it. **Where the two languages put the last two.** TypeScript declares all seven on `CallHandler`, every one optional. Python declares five on `CallHandler` and keeps `on_video_frame` and `on_speaker_change` on two separate protocols, `VideoHandler` and `SpeakerHandler`. The reason is mechanical: Python's `CallHandler` is `runtime_checkable`, and a runtime check demands every member the protocol declares, so a sixth method there would make `isinstance(handler, CallHandler)` fail for every handler written before the vision lane existed. The server duck-types both, through the same guarded dispatch as the other five, so **implementing the method is enough** and inheriting from anything is never required. ## What the call gives back `CallSession` is the handler's only way to reach the socket. The handler seam is narrow; the session is wide, and the same seventeen members are on it in both languages. **What you read.** | Member | What it is | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `.call_id` / `.callId` | StandIn's id for this call. Authenticated: it is the value the handshake HMAC signed, not something a caller supplied. | | `.start` | The `session.start` that opened the call: caller identity, direction, thread, recording status. | | `.recording_active` / `.recordingActive` | Whether this call is being recorded, right now. One flag the server keeps current. | | `.speaker` | Who is speaking, when StandIn sends unmixed audio. Empty on the mixed path (`None` in Python, `undefined` in TypeScript), which is most calls, so treat it as a hint rather than something to depend on. | | `.participant_count` / `.participantCount` | How many people are on the call. The number behind the group-etiquette context sentences, for a plugin that would rather branch than hand a sentence to a model. | | `.answered` | Whether anything has actually taken this call yet. | | `.buffered_bytes` / `.bufferedBytes` | How much outbound data the socket has not flushed. The number a continuous sender watches before pushing another frame. Zero means no evidence of backpressure, not proof of an idle socket. | | `.media_time_ms` / `.mediaTimeMs` | The outbound audio timeline in milliseconds, and the clock a video frame must be stamped with. A wall clock keeps ticking through listening silence while this one does not, so stamping video from a wall clock makes the two drift apart on paper even when they are in step. | **What you act with.** | Member | What it does | | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `send_audio(pcm)` / `sendAudio(pcm)` | Send the agent's voice. The server owns the sequence number and the outbound timeline, so a swapped audio source cannot make timestamps jump backwards. Sending audio also marks the call answered. | | `cancel_playback()` / `cancelPlayback()` | Drop whatever agent audio StandIn still has buffered. | | `mark_answered()` / `markAnswered()` | Say that an agent has taken the call, for an agent that joins and listens before it speaks. Stamped once, never re-stamped. This is what the unanswered-call reaper below watches. | | `latest_video_frame(source)` / `latestVideoFrame(source)` | The newest frame the caller showed, or nothing. Synchronous: it reads what the frame loop already stored. With no `source` the screen share wins over the camera, because an agent asked to look is nearly always being asked about what is being shown. See [Vision and the avatar](/python-sdk/vision). | | `display_image(...)` / `displayImage(...)` | Draw an image on the bot's tile for a few seconds: a chart it just computed, a page it is quoting. | | `send_tile_frame(jpeg)` / `sendTileFrame(jpeg)` | Put one frame of continuous video on the tile, for a worker that produces video of its own. Latest wins, there is no handshake, and `TileStream` is what paces it. | | `express(emotion)` | Hint the emotion the avatar should wear. Best-effort and video only: an unknown emotion renders as neutral. | | `send_speech_marks(marks)` / `sendSpeechMarks(marks)` | The viseme timeline for one utterance, which is what drives lip-sync. See [The avatar](/python-sdk/avatar). | | `end(reason)` | End the call. Idempotent, and the first reason wins, so a cascade of close causes cannot overwrite the one that actually ended it. | **The SDK sends the hints and receives the frames. StandIn draws the tile.** `express`, `send_speech_marks`, `display_image` and `send_tile_frame` go out; `latest_video_frame` and `on_video_frame` come in. A missing hint is ignored, not a failed call, and none of it changes a sample of what the caller hears. **Gate storage on `recording_active` / `recordingActive`, not on a sentence you remember seeing.** Anything that stores or ships what the caller said or showed to a third party belongs behind this flag. The server starts it from the `session.start` snapshot and then follows every later recording status change, and **a reported change wins over the snapshot whichever arrives first**. An omitted snapshot field means the state was unknown at answer time, not "not recording": letting the snapshot overwrite a status that landed first would silently shut every recording-gated capability for the whole call. A recorded call is one the caller was told is being kept. An unrecorded one is not. **`cancel_playback()` is the only lever that un-sends audio already handed to the service.** Call it the moment your provider reports the caller started speaking, and call it **before** you cancel the response upstream. Without it a barge-in stops the model but the bot keeps talking for the length of the buffered PCM, which is the single most common "it ignores me when I interrupt" report. `end()` is safe to call from inside `on_start`, which is how a plugin refuses a call it does not want. It returns immediately there, and the close runs once `on_start` unwinds. ## Audio, and why the helpers are in the SDK The wire is **PCM16, 16 kHz, mono, little-endian**, in both directions. Almost nothing else is. The realtime speech-to-speech models speak 24 kHz, most TTS vendors emit 22.05 or 24 kHz, and none of them chunk on the wire's frame boundary. So every plugin that is not a pure passthrough ends up writing the same two things: a resampler, because the rates differ, and a frame aligner, because a resampled buffer does not divide evenly into the wire's 640-byte frame, and dropping the remainder clips the end of every turn. Both live in the SDK, in both languages, because they are properties of the **wire**, not of any framework. They are dependency-free on purpose: speech at these rates does not need a windowed-sinc filter, and the alternative is putting numpy or scipy on the critical path of every audio frame of every call. | Constant | Value | | ------------------------- | ------- | | `SAMPLE_RATE_HZ` | `16000` | | `REALTIME_SAMPLE_RATE_HZ` | `24000` | | `FRAME_MS` | `20` | | `FRAME_BYTES` | `640` | | `BYTES_PER_SAMPLE` | `2` | | `NUM_CHANNELS` | `1` | Helpers: `resample_pcm16` / `resamplePcm16`, `frame_duration_ms` / `frameDurationMs`, and `FrameAligner` with `push`, `flush`, `reset` and `pending`. ## Two lanes Calls and chat are separate sockets with opposite directions, and that difference decides what you have to expose. | Lane | Who dials | Transport | What you expose | | ----- | ------------------------- | ------------------------------------------------------- | -------------------------------------------- | | Calls | StandIn dials your worker | WebSocket, one per call, at `/msteams/calling/{callId}` | port `9442` by default, published over HTTPS | | Chat | your worker dials StandIn | one long-lived WebSocket | nothing | Because the chat lane is dialed out, Microsoft Teams chat needs no listener, no open port and no tunnel, and your agent never holds a Bot Framework credential. StandIn owns the Microsoft Teams bot, authenticates the activity, resolves it to your connection, and strips the bot @mention before your handler sees the text. Both lanes authenticate with your connection secret. One key covers both, and that is the usual case. The chat lane reads `STANDIN_CHAT_SECRET` first and falls back to `STANDIN_SECRET`, so a deployment that issued a separate key for chat sets the extra variable and changes nothing else. `ChatChannel`, `InboundMessage`, `parse_inbound` / `parseInbound`, `build_reply` / `buildReply`, `PersonalChats` and `SCHEMA_VERSION` are in both SDKs. Two names differ, and each one is a first-line failure if you assume otherwise: * **The personal-or-group branch.** Python reads it off the message as the `msg.is_personal` property. TypeScript exports `isPersonal(msg)` as a free function. * **`DEFAULT_CHAT_URL` is at the TypeScript barrel and not the Python one.** Python keeps it in the module, so it is `from standin.chat import DEFAULT_CHAT_URL`, not `from standin import ...`. Neither is usually needed: `ChatChannel` already falls back to it, and `STANDIN_CHAT_URL` overrides it. `build_reply` / `buildReply` echoes `tenantId` and `conversationId` from the inbound message **exactly**. StandIn rejects a mismatch, and that check is the cross-tenant leak guard the whole relay rests on. It echoes `bindingId` too when the inbound message carries one, for the same reason one level down: a single tenant can hold several connections, so the tenant alone no longer says which one a reply is from. Do not construct a reply payload by hand. ## Authentication There are three signing helpers, not two. They sign different things, they carry different freshness windows, and none is a substitute for another. The names below are the Python ones; TypeScript has the same three in `camelCase`. | | v1 handshake | v1 body | v2 request | | ---------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | Functions | `sign_handshake` / `verify_handshake` | `sign_body` / `verify_body` | `canonical_request` and `sign_request` | | Signs | `"{timestampMs}.{id}"` | `"{timestampMs}.{rawBody}"`, the exact transmitted bytes | `"METHOD\npath\nsha256_hex(body)"`, so the method and the route are inside the signature, not only the bytes | | Freshness window | 60 s (`REPLAY_WINDOW_MS`) | 300 s (`CHAT_REPLAY_WINDOW_MS`) | 60 s where the SDK verifies it | | Used by | both WebSocket lanes. The signed id is the call id on the call lane and the channel name on the chat lane | a POST relay that wants the payload itself protected. Exported for your own use; no lane in the SDK runs it | the outbound place-call request the SDK sends, and the call-outcome route it verifies | Headers are `X-StandIn-Timestamp`, `X-StandIn-Signature` and `X-StandIn-Signature-V2`. Verification fails closed on empty input, and each handshake is single-use, so a replayed upgrade is rejected even inside its window. Sign the exact bytes you will transmit. Parsing JSON and re-serializing it can change whitespace, key order or Unicode escaping, and any one of those turns a valid signature into a rejection nobody can explain from the payload alone. The secret in your worker must **byte-match** the one registered in StandIn, or the handshake fails and no call connects. It fails silently by design: an endpoint that explains why it rejected you is an endpoint that helps an attacker, so budget for reading the value rather than for reading an error message. The same is true one lane over: a chat channel signing with the wrong key gets no explanation either, which is why `STANDIN_CHAT_SECRET` is worth checking whenever chat is the half that is quiet. ## Capacity and the watchdogs `CallServer` refuses work rather than degrading under it. There are 64 slots by default (`max_connections` / `maxConnections`), and a dial that arrives while the server is draining, or when every slot is taken, gets `503`. Both checks run **before any signature is verified**, so a flood cannot make the worker spend CPU on crypto for calls it was never going to accept, and a worker that is winding down stops taking calls it will never finish. Five bounds then sit on a call that nobody closes, four of them on by default: | Bound | Option | Default | What it catches | | ---------------------- | ------------------------------------------------- | --------- | --------------------------------------------------------------------------------------- | | pre-start | `pre_start_timeout` / `preStartTimeoutMs` | 10 s | a socket that authenticated and then never sent `session.start` | | `on_start` | `on_start_timeout` / `onStartTimeoutMs` | 15 s | a handler whose startup hangs, so the caller is not left in silence | | audio idle | `audio_idle_timeout` / `audioIdleTimeoutMs` | 45 s | a live call that stopped carrying caller audio | | unanswered-call reaper | `stale_call_reaper_seconds` / `staleCallReaperMs` | 120 s, on | a call **nothing ever answered**. Closes with `no-agent-answered` | | per-call ceiling | `max_call_seconds` / `maxCallMs` | 0, off | a call that is still going and has no reason to stop. Closes with `call-duration-limit` | **The reaper is the one none of the other three catch.** An agent dispatch that never lands leaves `session.start` delivered, `on_start` returned, and the caller still talking, so the pre-start, the `on_start` timeout and the audio-idle watchdog are each satisfied while the caller sits on a live call hearing nothing. **Answered** means audio went out: `send_audio` marks it for you, so every plugin is covered without writing anything. `mark_answered()` exists for the agent that joins and listens first, and a plugin that joins a room should call it when the agent's own audio track appears, not when a participant connects. Monitors, recorders and avatar workers all connect, and none of them is an agent answering. The ceiling is the polite one. Set `max_call_seconds` and the server flushes playback, speaks `goodbye_text` through the handler's own `on_goodbye`, allows `goodbye_grace` (6 s by default) for it to finish, and then closes. The goodbye goes through the callback StandIn's own closing line already uses, so no plugin needs new code to honour it. Teardown is idempotent and shielded, and always frees the slot. ## Microsoft Graph permissions Bringing your own Microsoft Teams bot means registering an Entra app and an Azure Bot resource in your tenant, admin-consenting the application permissions, and pointing its calling webhook at StandIn. See the canonical [Graph permissions table](/teams/azure-bot#8-grant-graph-permissions). ## The protocol is generated `protocol/schema.yaml` in the repository is the single source of truth for the wire. Both SDKs' protocol modules are generated from it and gated against drift, and shared conformance vectors assert that Python and TypeScript produce byte-identical payloads. Neither module is hand-edited, which is why the two languages cannot quietly diverge. # What the SDK gives you Source: https://docs.komaa.com/concepts/features The capabilities the StandIn SDK actually exposes: the call seam, audio, vision and the avatar, recap, outbound calling, the chat lane, signatures and capacity, and what it deliberately does not do. This page is a boundary, not a brochure. It says what is in the SDK, what is in the hosted service, and what belongs to your framework, so you can tell the three apart before you design around one. Everything below ships in **both languages**, unless a line says otherwise. Names are `snake_case` in Python and `camelCase` in TypeScript: where only one spelling appears below, the other is the mechanical translation of it, and the handful of names that are **not** a mechanical translation are listed in [Parity, and where it stops](#parity-and-where-it-stops) at the end. Each section ends where the page that owns it begins. ## Calls * **Seven optional callbacks.** `on_start`, `on_caller_audio`, `on_context`, `on_video_frame`, `on_speaker_change`, `on_goodbye`, `aclose`, in `camelCase` on the TypeScript side. Implement what you need and leave the rest out. `on_video_frame` and `on_speaker_change` are optional in exactly the same way as the other five even though Python declares them on two protocols of their own: the server calls whichever of the seven your handler actually has, so implementing the method is the whole requirement and inheriting from anything never is. See [the seam](/concepts/architecture#the-seam) for why the split exists. * **One handler per call**, built by a factory that closes over your configuration, so nothing is threaded through the server. * **`cancel_playback()` / `cancelPlayback()`**, the only wire-level lever that un-sends audio already handed to the service. This is what makes a barge-in actually stop the bot. * **`end(reason)`**, idempotent and first-reason-wins. Safe to call from inside `on_start` to refuse a call. * **Call context as plain sentences.** Participant counts and group etiquette, DTMF key presses and recording status arrive through `on_context` already phrased to put in front of a model. * **Errors are contained.** An exception from any handler method ends that call alone. ## Audio * **`resample_pcm16` / `resamplePcm16`**, linear interpolation, dependency-free. An odd trailing byte is dropped rather than raised: a truncated frame is a glitch, a raised exception in the audio path is a dropped call. * **`FrameAligner`** with `push`, `flush`, `reset` and `pending`, because a resampled buffer does not divide evenly into the 640-byte wire frame and dropping the remainder clips every turn. * **`frame_duration_ms` / `frameDurationMs`**, for a playout clock. Use it rather than counting frames: outbound chunk lengths are not fixed, so a frame count drifts against real time. * **The wire constants**, named rather than inlined: `SAMPLE_RATE_HZ` 16000, `REALTIME_SAMPLE_RATE_HZ` 24000, `FRAME_MS` 20, `FRAME_BYTES` 640, `BYTES_PER_SAMPLE` 2, `NUM_CHANNELS` 1. ## Turning speech into a reply **There are two dialogue modes and the SDK carries both.** In the realtime one, a single speech-to-speech provider hears the caller and answers in voice, and it does its own turn-taking. In the streaming one, speech becomes text, your agent answers, and the answer becomes speech again. That second shape needs somebody to decide where the caller stopped talking, ask three services in order, and feed the answer out at the rate a call consumes audio. That somebody is in the SDK. * **`VoiceLane`**, built with your `transcribe`, `answer` and `synthesize`. It runs one turn at a time, a new utterance supersedes the one in flight rather than racing it, and a failed step is **spoken** rather than logged and swallowed. Silence is the one thing a caller cannot interpret. * **`UtteranceSegmenter`**, with a pre-roll so the syllable that trips the gate is not lost, a silence window so a pause inside a sentence is not the end of one, a floor so a cough is not a turn, and a ceiling so a stuck-open microphone does not grow one utterance for the whole call. * **`PacedPlayback`**, and `decode_wav` / `encode_wav` for the 32-bit float, 44.1 kHz or extensible WAV a speech engine hands back, every one of which plays as noise on a call unconverted. * **Barge-in is handled inside the lane**: buffered audio is dropped the moment a new utterance opens, not when the old one finishes. [Turn-taking](/python-sdk/voice) has the whole lane. [Dialogue modes](/concepts/modes) is where to start if you are still choosing a shape. ## In a meeting, and who is speaking An agent that answers every turn of a meeting is worse than one that was never invited, so the etiquette is in the core rather than in each plugin. * **Silent until addressed.** `GroupGate` answers the turn that named the agent through one of your wake phrases, and then keeps the floor open for `DEFAULT_FOLLOW_UP_WINDOW_MS`, twelve seconds, so the follow-up question does not have to say the name again. `GateDecision` reports `respond` and `addressed` separately, because a turn answered inside the window was not addressed and only an addressed turn reopens it. `is_verbal_interrupt` sits beside the gate for the whole utterances that are only "stop", "wait" or "never mind". * **A 1:1 always answers.** The gate arms itself off `session.start.thread_id`: a meeting or channel conversation begins `19:` and arms it, a one-to-one call has no such thread at all, and every turn of one is answered with `addressed` still reported truthfully. `note_participants(count)` is a second, weaker signal that can only add certainty: a participant count never arrives on the meeting-join path, so a gate keyed on the count alone is dead on exactly the calls it exists for. * **A gate nothing could open is off.** Configure no wake phrase and `gate.active` reports false and every turn goes through. A gate with nothing that could ever open it would mute the agent for the whole call, and silence is the one failure a caller cannot tell apart from a dead worker. * **Who said it.** `session.speaker` and the optional `on_speaker_change(name)` callback name the person talking, which is what attributes a transcript line and tells a model who it is answering. Both arrive only where StandIn sends unmixed audio: on the mixed path, which is most calls, `session.speaker` is `None`, so build on it as a hint and never as a requirement. `on_speaker_change` fires on change only, never per frame. [Group calls](/python-sdk/group-calls#wake-phrases-and-the-follow-up-window) has the gate, and [the optional callbacks](/python-sdk/call-handler#the-optional-callbacks) have the speaker. ## Seeing and being seen * **Frames in.** The caller's camera and screen share arrive as `VideoFrame`s over the same socket. They are sparse and best-effort by contract, the server keeps the latest per source, and `latest_video_frame()` is the common path because a model asks to look long after the frame arrived. `on_video_frame` is the ambient one, for a plugin that wants to know the moment one lands. * **Watching without being asked.** `AmbientVision` pushes what changed on screen into the conversation between turns, for the caller who shares a deck and says only "what do you think?". It is **off** unless you enable it, because it spends money on every scene change, and its recording gate is **on** by default: continuously sending somebody's screen to a model is a different promise from glancing at it once. It keeps a reserve so the ambient lane cannot spend the whole vision budget and leave the caller's own "look at this" with nothing left. * **Looking back at what is already gone.** The session keeps the latest frame per source, which answers "what am I looking at now". `KeyframeStore` answers "what was on that slide a moment ago", which is what somebody actually asks after they have moved on, and the `look_back` tool is how a model reaches it. Sixteen frames by default, latched per source so a screen nobody touched is kept once, and it keeps **nothing at all unless the call is being recorded**: a history of somebody's screen is a materially different promise from glancing at it once. * **A ceiling on looking.** `VisionBudget` allows six looks in any rolling minute by default, as a sliding window rather than a total, so a long call is not punished for having been long. A look that failed is refunded rather than charged. Its `reserve`, a quarter of the window and never less than two, is the part only an explicit look may spend, which is why the ambient lane must be handed the same budget object your `VisionTools` holds rather than building its own. * **Pictures out.** `display_image()` draws a chart, a page or a photo on the bot's tile for a few seconds. `DISPLAY_MODES` is the pair `fullscreen` and `overlay`, and the model picks one **per picture**: an overlay is a picture-in-picture inset that keeps the face on screen, and it is unreadable for a dense screenshot or a page of a document, which is exactly when somebody says "show me". * **The avatar hints.** `express()` names the emotion the face should wear, `send_speech_marks()` carries the viseme timeline that drives lip-sync, and `estimate_visemes()` and `TurnLipSync` estimate that timeline from the text for the providers that return no timings at all. * **Your own video.** `send_tile_frame()` and `TileStream` put continuous frames on the tile for the minority of workers that produce video themselves. Latest wins, and it drops under backpressure rather than queueing, because the voice matters more than the picture. * **A vision model is optional.** `FrameDescriber.from_env()` returns nothing when `STANDIN_VISION_API_URL` is unset, and that nothing is the signal a plugin uses to tell an agent that looking is not available here. [Vision and the avatar](/python-sdk/vision#the-tool-surface) and [The avatar](/python-sdk/avatar) own this lane. ## What the agent can do about the call * **Call tools**, declared once and rendered into each provider's own JSON by `tool_schemas()`. `BUILT_IN_TOOLS` is `end_call`, `express`, `show_image`, `look` and `look_back`, and the descriptions are written for a model rather than for you: they say when to reach for the tool, because that is the only thing the model reads before deciding. An unknown dialect falls back to the flat shape rather than raising, since a tool the model never sees is worse than one described a little loosely. * **Dispatch never raises.** The result is read out loud, so a failure has to arrive as a sentence or the agent simply goes quiet and the caller waits. * **Consulting and background work.** `CONSULT_TOOL` and `BACKGROUND_TASK_TOOL` let a model hand real work to your agent without stalling the call, and keep the promise to deliver it afterwards. See [Call tools](/python-sdk/call-tools) and [Consulting and background work](/python-sdk/consulting). ## After the call, and reaching people * **Meeting recap.** `Transcript` collects the turns, `post_meeting_minutes` is the tool a model calls to file them mid-call, and `post_minutes` is the function that writes them up and posts them. The OpenClaw and Hermes plugins also do this at hang-up when you turn `meetingRecap` / `meeting_recap` on: they write a restart-recoverable local spool first, then post through a listen-only chat lane. Off by default. Delivery needs the managed-bot chat lane ([Tiers](/tiers)) and a summarization consult. Hang-up does not await the consult. Meeting recaps are best-effort. They may be lost if the worker exits during processing, or if the spool directory is on disk that dies with the process. Point `STANDIN_RECAP_DIR` at a directory that survives a container restart when you need unfinished recaps to post after one. `minutes_prompt` asks for Key Points, Decisions, Action Items and, when the agent saw something, Presented, and it forbids inferring what was on screen: minutes that invent the numbers on a dashboard are worse than minutes with a gap. * **Minutes that say who said it.** Each `Turn` keeps its speaker apart from its words, and `write_minutes_docx` appends the transcript attributed line by line rather than filing the whole call under one name. That is the half a transcript-only recap tool cannot produce, and it is worth the most on the meeting calls where unmixed audio gives a real speaker per utterance. `has_speaker_prefix` keeps a line that already carries its own name from being relabelled or prefixed twice, because re-labelling destroys the one thing worth keeping and prefixing again reads as a transcription fault. The document is Word-openable with no dependency on anything: a `.docx` is a zip of four XML parts, and a document library would be a dependency every install pays for so that the few who ask for minutes get a file. Minutes are delivered as **text** to one conversation; the document is written **to disk**, and only when you name a `document_dir` for it. `DOCUMENT_NOT_ATTACHED` is the sentence the message then carries, because a document promised into the chat would be a promise that quietly fails. [Meeting recap](/python-sdk/minutes#the-document). * **Outbound.** The `call_me_back` tool (`CALL_BACK_TOOL`) rings somebody who asked to be rung, and `OutboundLane` with `PendingMessages` parks the line **on disk** until they answer, because the answering leg is a different call and may be a different process: an in-memory store loses the message silently and the callee picks up to nothing. The whole lane is **off until you switch it on**: `STANDIN_OUTBOUND_ALLOW` lists who this agent may ring, an unset list refuses every call, and `STANDIN_OUTBOUND_MAX_PER_HOUR` caps the rest at six an hour by default. It also needs the `Calls.Initiate.All` consent in the [Graph permissions table](/teams/azure-bot#8-grant-graph-permissions). [Reaching people](/python-sdk/reaching-people). * **Nobody answers, so it goes to chat.** Chat is the one fallback this lane has: a parked answer reaches the person in writing or not at all. When StandIn reports `no-answer`, `declined`, `busy` or `failed`, the line the agent was holding is posted into the conversation the request came from, prefixed so it reads as a missed call and worded for the person who missed it rather than for an operator. One idempotency key covers both the reported outcome and the answer-timeout sweep, because both can fire for one call and being told twice reads as a second attempt that never happened. The one case with nowhere to go is a call whose thread is not a real conversation, which `call_thread_is_postable` decides: that one is parked rather than posted somewhere wrong, because an answer delivered into the wrong conversation is the worst outcome this lane has. [The thread id is how an unanswered call still reaches them](/python-sdk/reaching-people#the-thread-id-is-how-an-unanswered-call-still-reaches-them). * **Speaking into a call already up.** `VoiceDelivery` says the line into a live call when there is one and only rings back when there is not, and it never raises: every refusal comes back as a sentence, because the thing reading it is either a host that would mark the whole platform failed or a model that would say the exception out loud. ## Getting a plugin right Four pieces most plugins used to carry themselves, each closing a failure that is invisible until a real call finds it. Two of them moved into the core precisely because copies had started to drift. [`GroupGate`](#in-a-meeting-and-who-is-speaking) above is the first of the four, and these are the other three. * **The echo guard**, so a speakerphone does not feed the agent its own playback and leave the model answering itself in a loop while the caller is silent. It is the `EchoGuard` class in Python and the `shouldSuppressEcho` function in TypeScript. * **`StartupBuffer`**, for what arrives before your provider is ready. Both halves matter: the caller's first words are often the reason they called, and the first context sentence is what engages a group gate and a recording gate. Five plugins in this SDK once held one half and silently lost the other. * **`run_smoke`**, which rings this worker's own handler on loopback and proves audio made the round trip, with no provider bill, no public tunnel and no Microsoft tenant. See [Group calls](/python-sdk/group-calls), [Realtime providers](/python-sdk/realtime-providers) and [Checking the install](/python-sdk/checking-the-install). ## Chat * **`ChatChannel`**, dialed out from your worker. No listener, no open port, no tunnel, and no Bot Framework credential in your agent. * **`InboundMessage`**, already authenticated, resolved to your connection, and with the bot @mention stripped. In group and channel scope only messages that mention the bot are relayed at all. * **`parse_inbound` / `parseInbound` and `build_reply` / `buildReply`.** The reply echoes `tenantId` and `conversationId` exactly, which is the cross-tenant leak guard the relay rests on. * **The personal-or-group branch**, which nearly every agent needs. Python reads it off the message as the `msg.is_personal` property; TypeScript exports `isPersonal(msg)` as a function. Same answer, two shapes, so check the page for your language rather than assuming. * **Attachments become one turn.** A pasted screenshot, a dragged-in file and a voice note all land as one `ChatTurn` your agent can answer, and a reply can carry a picture back. See [Attachments in chat](/python-sdk/chat-attachments) and [Sending a picture](/python-sdk/sending-pictures). * **A voice note becomes text.** `transcribe_voice_messages` fetches the clips and runs the `Transcriber` you supply, and `ChatTurn.voice_note` is what they said. Supply no transcriber and it returns empty rather than raising, and one clip that fails costs that clip and nothing else: a message with a picture and a voice note must still be answered. [Voice notes](/python-sdk/chat-attachments#voice-notes). * **Per-conversation ordering and at-least-once dedupe**, so replies in a conversation cannot overtake each other and a redelivery does not run your agent twice. * **`SCHEMA_VERSION`** is a major version. Additive evolution does not bump it, because the schema already requires receivers to ignore unknown fields. ## Security HMAC-SHA256 over `{timestampMs}.{id}`, a 60 s freshness window, and a single-use replay guard, so a captured upgrade cannot be replayed even inside the window. The v2 scheme binds the **method and the route** as well as a hash of the body, so a signature lifted from one request cannot be replayed against another. The SDK verifies it inside a 60 s window on the call-outcome route. Empty inputs fail verification. A bad secret is rejected without explaining why, which is correct and is also why a secret mismatch looks like silence. `call_id` is the value the handshake signed, never something a caller supplied, so it is safe to key your own state on. The handshake proves the call came from StandIn; `session.start` then names the human. Deciding whether to answer **that** person is a deployment policy, and it is closed by default: an unset or unknown policy refuses, so a typo in a config file cannot open the agent to everyone. Who may call is the one security decision that is not identical in the two languages. TypeScript ships it: `isInboundCallAllowed(inboundPolicy, allowFrom, from)` admits everyone under `"open"`, checks the list under `"allowlist"` and `"pairing"`, and refuses everything else, including `"disabled"` and an unset policy. `"pairing"` is enforced as a plain allowlist, because no pairing code is issued for a call: read it as a second name for `"allowlist"` rather than as a second mechanism. `isAllowlistedCaller` matches an exact caller id case-insensitively **or** a digits-only phone number, because a Microsoft Teams caller arrives as a directory object id that phone normalization would reduce to nothing; and `describeInboundRejection` writes the log line that tells an operator which list to add them to. Python ships no policy module: you write the same check in `on_start` and call `end(reason)` to refuse, which is [Refusing a call](/python-sdk/call-handler#refusing-a-call). [TypeScript](/typescript-sdk/security#who-may-call-the-agent), [Python](/python-sdk/security#what-the-secret-does-not-decide). **One flag gates everything you keep.** `recording_active` / `recordingActive` says whether this call is being recorded right now. Gate on it before anything that **stores** what the caller said or showed with a third party, and read it off the session rather than re-deriving it from whichever context sentence you happened to catch. The server starts it from the `session.start` snapshot and then follows every later recording status change, and a reported change wins over the snapshot whichever arrives first: an omitted snapshot field means the state was unknown at answer time, not "not recording". **Know what it does and does not switch off.** Two things in the core gate what they **keep** on it: ambient vision, which checks it before a frame is even stored and has `require_recording` on by default, and `KeyframeStore`, which keeps nothing without it and so makes `look_back` a recorded-call capability. Nothing else in the core gates on it, and the **audio path does not**: `on_caller_audio` delivers every frame whether or not the call is being recorded, and an on-demand `look` at the current frame is not gated either. It is a flag about what may be **kept**, not a switch that stops media reaching your worker, so anything else you store stays yours to gate. Two more things read the flag, and neither is on that list. `OutboundLeg` reads it as a **signal** rather than a gate: recording turning active is the moment it treats the person as having picked up, because recording turning on is what happens when a Microsoft Teams call is actually connected and that is the nearest thing to a "they answered" there is. [Reaching people](/python-sdk/reaching-people) has that leg. A plugin may also add a gate the core does not have, and **the two shipped realtime plugins default opposite ways**, so read the page for the one you run rather than assuming. The Hermes plugin's [`require_recording`](/python-sdk/plugins/hermes-agent) keeps the caller's audio out of the model until the Microsoft Teams recording banner is up, and it is **on** unless you turn it off. The OpenClaw plugin's [`requireRecordingStatus`](/typescript-sdk/plugins/openclaw) does the same thing and is **off** unless you set it to exactly `true`. Both call-handler pages carry the flag itself: [Python](/python-sdk/call-handler), [TypeScript](/typescript-sdk/call-handler). Two capabilities are **off until you turn them on**, and that is the security property rather than a default worth changing casually: an unset `STANDIN_OUTBOUND_ALLOW` refuses every outbound call, and an unset `STANDIN_SHOW_ROOTS` (a document onto the call tile) or `STANDIN_MEDIA_ROOTS` (a picture into a chat reply) refuses every local file. The paths reaching those two were chosen by a model that somebody is steering. A URL is the same problem wearing a trusted costume, so the SDK fetches one an agent chose through its own guard rather than with a plain `GET`: http and https only, no embedded credentials, no host that resolves into private, loopback, link-local or reserved space, the address re-checked at connect time so a name that answers publicly once and privately a moment later is still refused, and at most one redirect hop, re-guarded. `169.254.169.254` is cloud credentials and `127.0.0.1` is whatever else you run, which is why this is in the core rather than in each plugin. The three helpers are top-level exports in TypeScript and live in `standin.fetch` in Python: [Python](/python-sdk/security), [TypeScript](/typescript-sdk/security). ## Running it * **Capacity and draining.** 64 slots by default. A dial that arrives while draining, or with no free slot, gets `503` **before any signature is checked**, so a flood cannot make the worker spend CPU verifying calls it was never going to take, and a worker winding down does not accept one it will never serve. Refusing beats degrading. * **One live session per call id.** A second dial for a call that already has one gets `409`, so a retry cannot quietly run two handlers against the same conversation. * **Five bounds on a call nobody closes**, four of them on by default: pre-start (10 s), `on_start` (15 s), caller-audio idle (45 s), the unanswered-call reaper (120 s, closing with `no-agent-answered`), and an optional per-call ceiling that speaks a goodbye through your `on_goodbye` before it closes. The reaper is the one the other three cannot catch: an agent dispatch that never lands leaves every other timer satisfied while the caller hears nothing. **Answered** means audio went out, so `send_audio` covers every plugin without any code, and `mark_answered()` is there for an agent that joins and listens first. [The table is on Architecture](/concepts/architecture#capacity-and-the-watchdogs). * **Agent audio is shed, not queued.** Past 1 MiB of unflushed outbound buffer the server drops agent audio frames rather than growing a queue, and the outbound timeline still advances, so the caller hears a gap rather than a rewind. Control frames are never shed, because a call that cannot be ended is the failure this exists to prevent. `buffered_bytes` / `bufferedBytes` is the number a continuous sender watches. * **Idempotent, shielded teardown** that always frees the slot, including on cancellation. * **Environment-only configuration.** No config file, nothing to keep in sync, and nothing read at import: a variable is read when the thing that needs it is built, so a worker that never opens a lane never needs that lane's settings. Every `STANDIN_` variable is in one table on the Configuration page, [Python](/python-sdk/configuration) and [TypeScript](/typescript-sdk/configuration). * **Log injection is handled.** Ids that reach a log line have control characters replaced and length bounded, so an attacker-influenced value cannot forge log lines. ## What is not in the SDK Being clear about this saves you a wasted afternoon. | Not in the SDK | Where it is instead | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Speech recognition, speech synthesis, agent reasoning | Your framework and your provider. The SDK has no recognizer, no voice and no model. It ships the plumbing that runs yours. | | Drawing the avatar tile | StandIn. Your worker sends the hints and the frames; StandIn draws the face. | | Joining the Microsoft Teams call | StandIn. It joins the call and dials your worker. | | A browser | Yours. `PageRenderer` is the seam: you supply the thing that turns a URL into an image. `show_page` is deliberately **not** one of the built-in tools, because a deployment with no renderer would otherwise promise every model it can show a page and then apologise on every call. Register it when you have one. | | A PDF or Office rendering engine | Not in the base install. `pip install "standin-sdk[render]"` adds it, and it is **Python only**: the TypeScript SDK has no document renderer and reads no `STANDIN_SHOW_ROOTS`. | The avatar row is the one people get wrong in both directions, so to say it plainly: `express`, `send_speech_marks`, `display_image` and `send_tile_frame` go out of your worker, the caller's frames come back in, and **the SDK gives you an API for both directions**. It is only the drawing that is not yours. Each plugin states its own scope on its own page, and [Dialogue modes](/concepts/modes) compares what the shipped plugins drive. ## Parity, and where it stops `protocol/schema.yaml` generates both SDKs' protocol modules, a drift check fails the build if a generated file is edited by hand, and shared conformance vectors assert that Python and TypeScript produce identical payloads. **For the call wire protocol and the signatures, "the two languages behave the same" is a test result rather than an intention.** Everything above the wire is mirrored by convention instead, and convention has gaps. They are small, and they are worth knowing before you assume a missing symbol is a typo: | Difference | Which way | | --------------------------------------------------------------------------- | --------------------------------------------------- | | `is_personal` is a property; `isPersonal(msg)` is a function | Python / TypeScript | | `DEFAULT_CHAT_URL` is at the barrel | TypeScript only. Python keeps it in `standin.chat` | | The public-URL fetch guard is at the barrel | TypeScript only. Python keeps it in `standin.fetch` | | The echo guard is the `EchoGuard` class; `shouldSuppressEcho` is a function | Python / TypeScript | | Document rendering and `STANDIN_SHOW_ROOTS` | Python only | | Inbound-call allowlist helpers | TypeScript only | Each SDK page names the difference for its own language where there is one, so read the page for the language you are writing rather than translating the other. # Dialogue modes Source: https://docs.komaa.com/concepts/modes Realtime speech-to-speech or a streaming STT, agent, TTS pipeline: how much of each the SDK carries, how to choose, and which mode every shipped plugin drives. The SDK carries audio and it has **no mode setting**. Nothing you configure selects a mode: the shape of the middle is decided by which provider you hand the audio to, and you can change your mind without touching the seam. What the SDK does bring is the middle itself, for one of the two shapes. It contains no recognizer, no voice and no model, and it never will. It does contain the assembly that runs yours. **Speech-to-speech.** Caller audio goes straight to a realtime model, and the model's audio comes straight back. Lowest latency, natural turn-taking. Needs a realtime provider key. The turn-taking is the provider's, so the SDK stays out of the way. **STT, then your agent, then TTS.** Reuses the speech stack and tool pipeline you already run. More moving parts, more control over vendors. `VoiceLane` runs the three in order for you, so "more moving parts" is mostly three functions you supply. ```mermaid theme={null} flowchart LR IN["Caller audio
from StandIn"] --> H["Your handler"] subgraph RT["Realtime"] RM["Realtime model
speech-to-speech"] end subgraph ST["Streaming: VoiceLane runs this"] direction LR STT["STT"] --> AG["Your agent"] --> TTS["TTS"] end H -->|realtime| RM H -->|streaming| STT RM --> OUT["Reply audio
to StandIn"] TTS --> OUT ``` ## The streaming middle is already written Going streaming used to mean writing four things before the first word came back: work out where the caller stopped talking, transcribe it, ask the agent, and feed the answer out at the rate a call consumes audio. `VoiceLane` is those four, in order, holding one turn together. ```python Python theme={null} from standin import CallSession, VoiceLane class MyHandler: async def on_start(self, session: CallSession) -> None: self.lane = VoiceLane( session, transcribe=my_stt, answer=my_agent, synthesize=my_tts, ) async def on_caller_audio(self, pcm: bytes) -> None: await self.lane.feed(pcm) async def aclose(self, reason: str) -> None: await self.lane.aclose() ``` ```typescript TypeScript theme={null} import { VoiceLane, type CallSession } from "@komaa/standin-sdk"; class MyHandler { #lane!: VoiceLane; async onStart(session: CallSession) { this.#lane = new VoiceLane(session, myStt, myAgent, myTts); } async onCallerAudio(pcm: Buffer) { await this.#lane.feed(pcm); } async aclose(reason: string) { await this.#lane.aclose(); } } ``` You supply three callables: `transcribe` takes one whole utterance and returns the words, `answer` takes that transcript and returns the reply, `synthesize` takes reply text and returns PCM. The last two may each return an **async iterator** instead of a single value, which is what gets the first sentence out before the last one exists; the lane tells the two apart at runtime, so neither callable needs a flag to say which it is. The lane owns everything that makes a working provider sound broken when you get it wrong: * **One turn at a time.** An agent asked two questions at once answers neither well, so a new utterance supersedes the one in flight rather than racing it. * **Barge-in that lands.** The buffered audio is dropped at the moment the new utterance opens, not when the old one finishes. A second of talking at somebody who has stopped listening is the whole difference between a call that feels alive and one that does not. * **A silence is not a turn.** The segmenter opens on a loud frame, and waking the agent for every cough and door is both a bill and a caller being answered at random. * **Failures are spoken.** A step that fails says so, out loud, in a sentence. Nothing here raises into the call, because somebody on a phone cannot tell a broken transcriber from an agent that is thinking, and will keep waiting. Nothing forces you to use it: `UtteranceSegmenter` and `PacedPlayback` are exported separately for a plugin that wants the pieces and not the assembly, and a realtime plugin imports none of it and pays nothing for it. [Turn-taking](/python-sdk/voice) is the full lane, in [TypeScript](/typescript-sdk/voice) too. ## Which mode each plugin drives | Plugin | Mode on a call | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Hermes** (Python) | Realtime. The model holds the conversation and reaches Hermes when it calls the consult tool, so the caller gets conversational latency and the agent's full tool set behind one door. | | **LiveKit** (both) | Whatever your LiveKit agent already does. The plugin turns the Microsoft Teams call into an ordinary LiveKit room and relays your worker's own agent into it, so a realtime `AgentSession` and an STT-LLM-TTS one both work unchanged. | | **OpenClaw** (TypeScript) | Realtime, using the voice provider OpenClaw is configured with. | | **OpenAI Realtime** (TypeScript) | Realtime. The model hears the caller's voice rather than a transcript of it, so there is no recognition step in the middle. | | **ElevenLabs** (both) | Realtime, decided on the ElevenLabs side. The agent you configured there holds the conversation. | | **Deepgram** (both) | Streaming, assembled per call. Speech to text, reasoning and speech are three providers configured on one session. | | **Cartesia** (both) | Whatever your Line agent does. The agent is your code on Cartesia's platform, and this plugin is transport. | | **Echo** (both) | Neither. It sends the caller's audio back, which is exactly what makes it a useful transport test. | ## Choosing | If you want | Use | | ----------------------------------------------------------- | --------- | | The lowest latency and the most natural turn-taking | Realtime | | Expressivity carried in the voice itself, such as tone | Realtime | | Your agent's full tool and retrieval pipeline on every turn | Streaming | | No realtime provider subscription | Streaming | | Tight control over which STT and TTS vendors you use | Streaming | The old reason to avoid streaming was the amount of code between the two ends. That reason is largely gone: the segmenter, the turn, the barge-in and the paced playback are in the SDK, and what is left is the three services you were going to pick anyway. A common middle path is realtime for the conversation with a tool that hands real work to your agent. That is what the Hermes plugin does: the model is good at conversation, the agent is good at work, and the split keeps both fast. ## Barge-in works the same either way Whichever shape you pick, the caller interrupting is a wire-level event, not a provider one. ```python Python theme={null} # the caller started speaking await session.cancel_playback() # drop what StandIn still has buffered await provider.cancel_response() # then stop the model ``` ```typescript TypeScript theme={null} // the caller started speaking await session.cancelPlayback(); // drop what StandIn still has buffered await provider.cancelResponse(); // then stop the model ``` `cancel_playback()` / `cancelPlayback()` is the only lever that un-sends audio already handed to the service. Call it first. If you only cancel upstream, the model stops but the bot keeps talking for the length of the buffered PCM, and the caller experiences a bot that talks over them. On the streaming side `VoiceLane` already does this for you, in the right order, the moment the segmenter opens a new utterance. On the realtime side it is yours to wire, and [Realtime providers](/python-sdk/realtime-providers) has the two traps that go with it: the agent answering its own playback on a speakerphone, and the audio that arrives before your provider is ready to hear it. ## Sample rates are a mode problem too Realtime models speak 24 kHz. The StandIn wire is 16 kHz. Streaming pipelines add a second rate at the TTS end, often 22.05 or 24 kHz. Neither chunks on the wire's 640-byte frame boundary. Use the SDK's `resample_pcm16` / `resamplePcm16` and `FrameAligner` rather than writing your own. The aligner exists because dropping a resampled buffer's remainder clips the end of every single turn, which is subtle enough to ship and irritating enough to be reported as "it swallows the last word". See [Architecture](/concepts/architecture#audio-and-why-the-helpers-are-in-the-sdk). # Expose your agent Source: https://docs.komaa.com/expose StandIn dials your worker from the internet. The one path to publish, the mount commands, and the probes that tell a real fault from a fake one. StandIn is hosted, so it reaches your worker **from the internet**. The call lane is the only thing you publish, and getting its path or its target wrong produces the same symptom: a call that never connects a bot, with nothing in your logs. This is the only page that carries the mount commands. Every other page links here. ## What you publish | Public path | Lane | Local target | | ----------------------------------- | -------------------------------------------------------------------- | ----------------------------------------- | | `/msteams/calling` | calls, a WebSocket, one connection per call | `127.0.0.1:9442`, path `/msteams/calling` | | `/msteams/calling/outcome/{callId}` | call outcomes, an HTTP `POST`, **only when your worker asks for it** | `127.0.0.1:9442`, same path prefix | The WebSocket is the only thing that has to be publicly reachable. StandIn dials `/msteams/calling/{callId}`, appending the call id itself. Both defaults are overridable with `STANDIN_PORT` and `STANDIN_WS_PATH`, but change them only if something else already owns the port: one URL shape across every worker is worth more than a tidier path. The second row needs no second mount. It sits under the same prefix, so a **path** mount already forwards it, and a whole-port mount does too. It exists only when your worker registers an outcome callback, `on_call_outcome` / `onCallOutcome`, which is how a worker that **places** calls hears that nobody answered instead of waiting out the ring. Register nothing and a `POST` there is a `404`, so a worker that only answers calls opens no extra surface. It is signed like everything else: a bad or stale signature is `401`, a body over the cap is `413`, and a good one is `204`. Outbound calling is covered in Reaching people ([Python](/python-sdk/reaching-people), [TypeScript](/typescript-sdk/reaching-people)). Two things are not on the list, for two different reasons. `GET /healthz` sits at the **root** of the call port rather than under the call path, so a path mount does not carry it, which is the point: it is a local check (see [Probe it by hand](#probe-it-by-hand)). A whole-port tunnel does carry it, along with everything else on that port, which is one more reason to prefer a path mount. And placing an outbound call is your worker dialling **out** to the StandIn address in `STANDIN_WORKER_URL`, so there is nothing inbound to publish for it. **The chat lane needs no mount.** `ChatChannel` dials **out** from your worker to StandIn, so there is no inbound chat path, no port and no tunnel rule for chat. See [Architecture](/concepts/architecture#two-lanes). **Bring-your-own Azure bot chat is not this SDK.** Microsoft delivers chat to the messaging URL on your Azure Bot. Publish that URL if you answer chat; skip it if you only take calls. The portal shows the value to register. Calls still use the calling row above. ## Mount the call path With Tailscale Funnel, map the **path** onto your one public host. The proxy target must **repeat the path**, or the request arrives at your worker stripped and 404s: ```bash theme={null} tailscale funnel --bg --set-path=/msteams/calling http://127.0.0.1:9442/msteams/calling ``` Set `STANDIN_HOST=127.0.0.1` in the worker so the listener is reachable only through the funnel. The SDK's own default is `0.0.0.0`, which suits a container behind an ingress and is too open for a laptop. **Do not mount a whole port.** `tailscale funnel --bg --https=8443 9442` publishes every route on that port, burns one of the three funnel slots (443, 8443, 10000), and puts a port in the URL you then have to type correctly everywhere. A path mount gives you a portless URL. Confirm it before you register anything: ``` $ tailscale funnel status https://..ts.net (Funnel on) |-- /msteams/calling proxy http://127.0.0.1:9442/msteams/calling ``` **A registered mount is not a working mount.** `tailscale funnel --bg` returns as soon as the rule exists, whether or not anything listens behind it. Its exit status proves nothing. Probe the path before you conclude the worker is exposed. ### Other tunnels ngrok, cloudflared and devtunnel forward a **whole local port** rather than a path: ```bash ngrok theme={null} ngrok http 9442 # -> https://.ngrok.app/msteams/calling ``` ```bash cloudflared theme={null} cloudflared tunnel --url http://localhost:9442 ``` ```bash devtunnel theme={null} devtunnel host -p 9442 --allow-anonymous ``` The path is preserved either way, so the public URL is still the host plus `/msteams/calling`. ### Behind an ingress In a cluster, point an ingress rule at the worker's service on `9442` and leave `STANDIN_HOST` at its `0.0.0.0` default. The ingress must allow WebSocket upgrades and must not strip the path. Read timeouts need to outlast a call, not a request: a long silence in a conversation is normal, and an idle timeout below your longest expected pause will cut live calls. Check the message size limit too. The listener accepts a single inbound WebSocket message of up to **2 MB**, and what crosses that socket is wildly uneven: one audio frame is under a kilobyte, while one frame of the caller's camera or screen share is orders of magnitude larger. A proxy that caps a message below 2 MB therefore breaks video on a call whose audio keeps working perfectly, and that is a hard symptom to attribute to the proxy. ## What you register in StandIn One field on your connection in the [dashboard](https://standin.komaa.com/dashboard): | Field | Value | | --------------------------------------- | -------------------------------------------------- | | **Agent calling URL** (agent voice URL) | `wss://..ts.net/msteams/calling` | Paste a bare host and StandIn appends the default path. Paste a full URL and it is respected as given. With a path mount there is no port in the value. The Azure Bot calling webhook (`https://.standin.komaa.com/api/calling`) is a StandIn URL you set in Azure. It is not the worker URL on this page. Set the webhook in [Create the Azure bot](/teams/azure-bot). Register the worker URL above in the StandIn dashboard. ## Probe it by hand **Always pass `--http1.1` when probing through a funnel.** Tailscale Funnel serves HTTP/2, which has no connection-level upgrade, so over h2 your `Connection: Upgrade` and `Upgrade: websocket` headers never travel as a handshake: what reaches the worker is an ordinary `GET`. A TypeScript worker answers that **404**, identical to a missing mount, and a Python worker answers **401**, because it checks the signature before it looks at the upgrade. Either way the answer is about your probe, not about your mount. People have chased that phantom for hours. ```bash theme={null} curl -si --http1.1 \ -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ https:///msteams/calling/probe | head -1 ``` The trailing `/probe` stands in for a call id. The probe carries no signature, so it can never open a session: you are asking the route to prove it exists, not to let you in. | You get | Means | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` | **Alive.** The mount works and the handshake rejected an unsigned probe, which is correct. Both SDKs answer this to a real upgrade with no signature. | | `404` | Nothing is mounted at that path, the mount target dropped the path, or the tunnel's own edge never forwarded the request. Rule out a missing `--http1.1` first: without it a TypeScript worker answers `404` to a probe it was perfectly happy to serve. | | `503` | **Also alive**, and refusing on purpose: the worker is draining, or every call slot is taken. Both are checked before the signature, so a real upgrade meets them before it meets the handshake. Nothing about the mount is wrong. | | connection refused, curl exit 7 | The mount exists but nothing is listening locally, or the bind address is wrong. | | timeout | The tunnel is up but the target is unreachable from it. | Run the same probe against `http://127.0.0.1:9442/msteams/calling/probe` to tell "the tunnel is wrong" from "nothing is listening". For a plain liveness check the SDK also serves `GET /healthz` at the **root of the call port**. A path-scoped funnel mount does not publish it, which is deliberate: it is a local check, so run it against `127.0.0.1`. ```bash theme={null} curl -s http://127.0.0.1:9442/healthz # {"ok": true, "calls": 0} ``` The `calls` count is the number of live sessions, which is also how you watch a drain finish. **A plain `GET` on the call path is not a health check.** A healthy worker on loopback, with no tunnel involved, answers it `401` in Python and `404` in TypeScript, and neither is a liveness signal: the Python listener is refusing an unsigned request, and the TypeScript listener is saying that nothing but an upgrade belongs there. Use `/healthz` for liveness and the upgrade probe above for routing, and never write a check around the call path's status code. # StandIn Source: https://docs.komaa.com/index Put your AI agent into a Microsoft Teams call. One SDK in Python and TypeScript, framework plugins, and runnable examples. **StandIn is your AI teammate in Microsoft Teams calls.** The hosted service joins the call and owns the Microsoft side. The SDK runs in your worker, answers the call StandIn dials, and hands the audio to your agent. There is one SDK, in two languages, with the same contract in both: `standin-sdk`, imported as `standin`. Plugins for ElevenLabs, Deepgram, Cartesia, LiveKit and Hermes, and an echo to test with. `@komaa/standin-sdk`. Plugins for ElevenLabs, Deepgram, Cartesia, OpenAI Realtime, LiveKit and OpenClaw, and an echo to test with. Install the one for your language and start from the quickstart: ```bash Python theme={null} pip install standin-sdk ``` ```bash TypeScript theme={null} npm install @komaa/standin-sdk ``` ## Start with your framework | Your agent | Language | Start with | | --------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | ElevenLabs | Python or TypeScript | [ElevenLabs Microsoft Teams connector](https://github.com/komaa-com/standin/tree/main/examples/elevenlabs-msteams-connector) | | Deepgram | Python or TypeScript | [Deepgram Microsoft Teams connector](https://github.com/komaa-com/standin/tree/main/examples/deepgram-msteams-connector) | | Cartesia | Python or TypeScript | [Cartesia Microsoft Teams connector](https://github.com/komaa-com/standin/tree/main/examples/cartesia-msteams-connector) | | OpenAI Realtime | TypeScript | [OpenAI Microsoft Teams connector](https://github.com/komaa-com/standin/tree/main/examples/openai-msteams-connector) | | LiveKit | Python or TypeScript | [LiveKit Microsoft Teams connector](https://github.com/komaa-com/standin/tree/main/examples/livekit-msteams-connector) | | Hermes Agent | Python | [Hermes Microsoft Teams connector](https://github.com/komaa-com/standin/tree/main/examples/hermes-msteams-connector) | | OpenClaw | TypeScript | [OpenClaw Microsoft Teams connector](https://github.com/komaa-com/standin/tree/main/examples/openclaw-msteams-connector) | | Something else, or your own | Python or TypeScript | [Quickstart](/quickstart), then the echo plugin in your language | ElevenLabs, Deepgram, Cartesia and OpenAI need nothing beyond the SDK itself: they are reached over an ordinary WebSocket, so installing StandIn is installing them. Not sure yet? Run the [quickstart](/quickstart). It answers a real Microsoft Teams call with an echo, which proves your secret, your tunnel and your StandIn identity before any agent is involved. ## How it fits together ```mermaid theme={null} flowchart LR T["Microsoft Teams call"] <--> S["StandIn hosted service"] S <-->|"Authenticated WebSocket: audio, and video when there is any"| W["Your worker: StandIn SDK + plugin"] W <--> A["Your AI agent"] ``` Two halves, and you run only one of them. **StandIn handles the Microsoft Teams connection.** It dials your worker over one authenticated WebSocket per call. **Your worker is the brain.** The SDK authenticates that connection and owns the wire protocol, audio timing, sequence numbers, capacity, draining and shutdown. Your handler or framework plugin connects the call to the agent and its voice provider. ## The seam A handler implements only the callbacks it needs. A missing one is a no-op, nothing inherits from anything, and the session it is handed is how it reaches the call. Here is a whole working plugin. ```python Python theme={null} from standin import CallServer, CallSession class EchoHandler: async def on_start(self, session: CallSession) -> None: self._call = session async def on_caller_audio(self, pcm: bytes) -> None: await self._call.send_audio(pcm) server = CallServer(handler_factory=EchoHandler) await server.start() ``` ```typescript TypeScript theme={null} import { CallServer, type CallSession } from "@komaa/standin-sdk"; class EchoHandler { #call!: CallSession; async onStart(session: CallSession) { this.#call = session; } async onCallerAudio(pcm: Buffer) { await this.#call.sendAudio(pcm); } } const server = new CallServer({ handlerFactory: () => new EchoHandler() }); await server.start(); ``` That is a real plugin, not a sketch: it answers a live Microsoft Teams call. A handler that wants only audio implements only `on_caller_audio` / `onCallerAudio`, and everything else stays out of the file. [Architecture](/concepts/architecture) has the full seam, the two lanes and the wire facts. ## What your agent can do on the call The echo above is the floor, not the ceiling. Every lane below ships in **both languages** and has a page of its own. The cards go to the Python pages; the [TypeScript SDK](/typescript-sdk/overview) carries the same set, one for one. Realtime speech-to-speech, or `VoiceLane` running your STT, agent and TTS in order with the turn-taking, the pacing and the barge-in already written. The camera and the screen share arrive as frames. The agent looks on demand, looks back at a slide already gone while the call is recorded, and shows a chart or a page back on the tile, full screen or as a picture-in-picture inset. Six looks a minute by default, so a model cannot look in a loop. Send the emotion and the viseme timeline that drives lip-sync. StandIn draws the tile; your worker sends the hints and never has to know how. Silent until a wake phrase names it, then twelve seconds of follow-up so the next question need not. Verbal interrupts, and the speaker's name where StandIn sends unmixed audio. A 1:1 call answers everything. One set of call tools declared in every provider's own JSON, plus consulting and background work for the jobs too slow to hold a call open. Key points, decisions and action items, plus a Word-openable document that appends the transcript attributed line by line to whoever said it. And a call back to whoever asked for one, once the answer exists. Three more things worth knowing before you pick a shape. * **Two dialogue modes, both in the SDK.** One realtime speech-to-speech provider doing its own turn-taking, or speech to text, then your agent, then speech, with the turn-taking, the pacing and the barge-in already written. [Dialogue modes](/concepts/modes). * **Chat is a lane of its own.** A pasted screenshot, a dragged-in file and a voice note land as one turn your agent can answer, voice notes transcribed by the engine you supply, and a reply can carry a picture back. [Chat](/python-sdk/chat). * **The expensive and the sensitive are off until you switch them on.** Outbound calling refuses everyone until you list who the agent may ring, then caps it at six an hour. A local file is refused until you name a root it may come from. No history of the caller's screen is kept unless the call is being recorded. And who may call the agent is a check you write, because the handshake proves the call came from StandIn and nothing more. [Security](/python-sdk/security). [What the SDK gives you](/concepts/features) is the full inventory, and the boundary that goes with it. ## Two lanes | Lane | Direction | What you expose | | ----- | ------------------------- | ------------------------------------------ | | Calls | StandIn dials your worker | one WebSocket path, port `9442` by default | | Chat | your worker dials StandIn | nothing at all | The chat lane is dialed **out** from the worker, so Microsoft Teams chat needs no listener, no open port and no tunnel, and your agent never holds a Bot Framework credential. Both lanes authenticate with your connection secret in `STANDIN_SECRET`, and one key covers both. The exception is a deployment that issued a **separate chat key**: put it in `STANDIN_CHAT_SECRET` and the chat lane prefers it. Set one key when you were given one, two when you were given two. [Connection modes](/teams/connection-modes) says which you have. ## Next Answer a real Microsoft Teams call with an echo, in about ten minutes. The Microsoft side: bot identity, app package, admin consent. The one path StandIn dials, and how to prove it is reachable. StandIn is independent software. It is not affiliated with, endorsed by, or sponsored by Microsoft. Microsoft and Microsoft Teams are trademarks of the Microsoft group of companies. # Audio Source: https://docs.komaa.com/python-sdk/audio resample_pcm16 and FrameAligner in the StandIn Python SDK: the 16 kHz wire, 24 kHz realtime models, and why the remainder matters. The StandIn wire is PCM 16 kHz, 16-bit, mono, little-endian in both directions. Almost nothing else is. Realtime speech-to-speech models speak 24 kHz. Most TTS vendors emit 22.05 or 24 kHz. None of them chunk on the wire's frame boundary. So every plugin that is not a pure passthrough ends up writing the same two things: * a resampler, because the rates differ * a frame aligner, because a resampled buffer does not divide evenly into the wire's 640-byte frame, and dropping the remainder clips the end of every turn They live in the SDK rather than in each plugin because they are properties of the **wire**, not of any framework. That is the same reason the sequence number and the outbound timeline live in `CallServer`. A plugin that has to reimplement them is a plugin the SDK failed. Both helpers are dependency-free on purpose. Speech at these rates does not need a windowed-sinc filter, and the alternative is putting NumPy or SciPy on the critical path of every audio frame of every call. ## Constants ```python theme={null} from standin import ( BYTES_PER_SAMPLE, # 2, 16-bit mono FRAME_BYTES, # 640 FRAME_MS, # 20 REALTIME_SAMPLE_RATE_HZ, # 24000 SAMPLE_RATE_HZ, # 16000 NUM_CHANNELS, # 1 ) ``` | Constant | Value | What it is | | ------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SAMPLE_RATE_HZ` | `16000` | The wire rate, both directions. | | `NUM_CHANNELS` | `1` | Mono. | | `BYTES_PER_SAMPLE` | `2` | 16-bit. | | `FRAME_MS` | `20` | The nominal frame StandIn sends. | | `FRAME_BYTES` | `640` | 20 ms of PCM16 at 16 kHz, so 320 samples. | | `REALTIME_SAMPLE_RATE_HZ` | `24000` | What the realtime speech-to-speech models speak. Named here rather than in a plugin because it is the **ratio** that forces the residual buffer, and that is an audio concern rather than a provider one. | ## frame\_duration\_ms ```python theme={null} from standin import frame_duration_ms ms = frame_duration_ms(pcm) # duration of a PCM16 mono buffer ``` Use this for a playout clock rather than counting frames. Outbound chunk lengths are not fixed, so a frame count drifts against real time. ## pcm16\_rms ```python theme={null} from standin import pcm16_rms level = pcm16_rms(pcm) # 0.0 to 1.0 ``` Root-mean-square amplitude of a PCM16 mono buffer, normalised to 0.0 - 1.0. How loud a frame is. It is here rather than in a plugin because three separate things want it and each plugin that wanted it had been writing it again: the [echo guard](/python-sdk/realtime-providers#the-agent-answering-itself) testing whether in-window sound is a real barge-in, the [utterance segmenter](/python-sdk/voice#one-utterance-per-phrase) deciding what counts as speech at all, and any handler doing its own level check. That shared definition is the point. **A level threshold only means something against the same normalisation everywhere**, so `DEFAULT_SPEECH_RMS` (0.02) and `ECHO_BARGE_IN_RMS` (0.04) are both numbers on this scale, and a threshold you tune in one place reads the same in the other. Dependency-free for the same reason the resampler is: this runs on every inbound frame of every call, and NumPy on that path buys microseconds at the cost of a wheel on every deployment. An odd trailing byte is dropped rather than raising. ## resample\_pcm16 ```python theme={null} from standin import REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ, resample_pcm16 # caller audio, 16 kHz on the wire, into a 24 kHz model to_model = resample_pcm16(pcm, SAMPLE_RATE_HZ, REALTIME_SAMPLE_RATE_HZ) # model audio, 24 kHz, back onto the wire to_wire = resample_pcm16(pcm_24k, REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ) ``` Linear interpolation over PCM16 mono. Equal rates return the input unchanged, and empty input returns empty, so it is safe to call unconditionally. An odd trailing byte is dropped rather than raising. A truncated frame is a glitch; a raised exception in the audio path is a dropped call. ## The remainder problem This is the part that is easy to miss, and it is audible. At 24 kHz to 16 kHz the ratio is 2:3, so a chunk only lands on a 640-byte boundary by luck. Take a realistic provider chunk: | Step | Bytes | | --------------------- | --------------------- | | Model chunk at 24 kHz | 9600 | | Resampled to 16 kHz | 6400 | | Whole 640-byte frames | 10 frames, 6400 bytes | That one divides cleanly. Now the chunk after it, arriving at 7000 bytes of 24 kHz audio, resamples to 4666 bytes: seven whole frames and a 186-byte remainder. Send the seven and drop the remainder and you have lost about 5.8 ms. Do that at every chunk seam for a whole turn and the word endings go clipped and clicky. `FrameAligner` exists so the remainder is carried instead of dropped. ## FrameAligner ```python theme={null} from standin import FrameAligner aligner = FrameAligner() for chunk in provider_audio: # arbitrary lengths for frame in aligner.push(chunk): # whole 640-byte frames await session.send_audio(frame) tail = aligner.flush() # end of turn if tail is not None: await session.send_audio(tail) ``` | Member | Does | | ----------- | ------------------------------------------------------------------------------------ | | `push(pcm)` | Adds a buffer and returns whatever whole frames are now available, keeping the rest. | | `flush()` | Zero-pads and returns the residual at end of turn, or `None` when empty. | | `reset()` | Drops the residual without emitting it. | | `pending` | Bytes currently held back, waiting for a whole frame. | `flush()` pads rather than drops, because the tail of the last word matters more than a few milliseconds of silence. `FrameAligner(frame_bytes=...)` takes a different frame size if you need one, but the wire's frame is 640 bytes and there is rarely a reason to change it. ## reset on barge-in Call `reset()` when the caller interrupts. The bytes the aligner is holding back belong to the turn that was just interrupted. Flushing them instead replays a fragment of the abandoned answer after the silence, which is worse than the interruption the caller was trying to make. The full barge-in sequence, in order: ```python theme={null} async def _on_barge_in(self) -> None: await self._call.cancel_playback() # un-send what StandIn still holds await self._client.cancel_response() # stop the model generating more self._aligner.reset() # drop the partial frame of the dead turn ``` `cancel_playback()` goes first because it is the part the caller can hear. See [Barge-in](/python-sdk/call-handler#barge-in-and-cancel-playback). ## A complete outbound path ```python theme={null} from standin import ( REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ, FrameAligner, resample_pcm16, ) class Playout: """Provider audio at 24 kHz, whole 16 kHz frames on the wire.""" def __init__(self, session) -> None: self._session = session self._aligner = FrameAligner() async def on_provider_audio(self, pcm_24k: bytes) -> None: pcm = resample_pcm16(pcm_24k, REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ) for frame in self._aligner.push(pcm): await self._session.send_audio(frame) async def on_turn_done(self) -> None: # The residual is real audio: pad it, do not drop it. tail = self._aligner.flush() if tail is not None: await self._session.send_audio(tail) async def on_barge_in(self) -> None: await self._session.cancel_playback() self._aligner.reset() ``` One aligner per call, not one per turn: it carries state across chunks and `reset()` is what clears it. ## Agent audio can be dropped Everything above gets whole frames onto the wire. The wire can still refuse them. When the socket has more than 1 MB (`MAX_AUDIO_BUFFER_BYTES`) unflushed, `send_audio` advances the timeline and returns **without sending**, and logs at most once every five seconds. The caller hears a gap rather than the call wedging, and the timeline still advances because a dropped frame is a gap in what they hear, not a rewind. Control frames are never shed: a call that cannot be ended is the failure this exists to prevent. Watch `session.buffered_bytes` before pushing a continuous stream, and treat a zero as no evidence of backpressure rather than proof of an idle socket, because it reads zero when the transport cannot report it. See [Backpressure](/python-sdk/call-server#backpressure). ## Inbound audio `on_caller_audio` receives the caller's PCM already decoded and already validated: base64 that is not canonical is rejected, and so is a payload that is not a whole number of PCM16 samples. A malformed frame is dropped by the server with a log line and never reaches your handler, so no aligner is needed on the way in. What you do **not** get is a guarantee of 640 bytes. 20 ms is the nominal frame and it is what arrives on a normal call, but the only invariant is an even byte count. Measure with `frame_duration_ms(pcm)` rather than counting frames, and never index into a caller frame at a fixed offset. Resample if your provider wants a different rate, and hand it over without blocking. ```python theme={null} async def on_caller_audio(self, pcm: bytes) -> None: await self._client.append_input( resample_pcm16(pcm, SAMPLE_RATE_HZ, REALTIME_SAMPLE_RATE_HZ) ) ``` ## Next Where these helpers sit in a real handler. The sequence number and timeline the SDK owns for you. # The avatar Source: https://docs.komaa.com/python-sdk/avatar Expressions, the viseme timeline that drives lip-sync, and putting your own video on the bot's tile, in the StandIn Python SDK. StandIn renders the avatar tile. Your worker does not draw it, stream it, or know how it is made. It sends hints, and the service does the rest. ## Two hints and one stream There are two hints. One names the emotion the face should wear, the other carries the viseme timeline for an utterance, which is what makes the mouth match the words: ```python theme={null} await session.express("happy") await session.send_speech_marks([(0, 12), (200, 4), (400, 14), (800, 8)]) ``` Everything here is **additive and best-effort**. An unknown emotion renders as neutral, a service that does not implement one of these ignores it, and nothing on this page changes a single sample of the audio the caller hears. That is the whole reason it is safe to send: the worst case is a neutral face over an unchanged voice, which is exactly what the caller would have got had you sent nothing at all. Neither hint needs a provider that offers it. The emotion can be read straight out of the reply text with no extra model call, and the timeline can be built for a provider that returns no timings whatsoever. Those two are the bulk of this page. Then there is the stream, for the minority of workers that already produce video of their own: `TileStream` puts that on the tile in place of the rendered avatar. It is the one thing here that is continuous rather than a hint, and the last section is mostly about not letting it hurt the voice. ## The emotion the face wears ```python theme={null} from standin import EMOTIONS, expression EMOTIONS # ("neutral", "happy", "sad", "surprised", "thinking") ``` `EMOTIONS` is an **open set**, not a validation list. Sending something outside it is allowed and renders as neutral, which is what lets a newer sender and an older service keep interoperating instead of one of them refusing the other's vocabulary. `session.express(emotion)` is what a handler calls. `expression(emotion)` is the message builder underneath it, exported for a plugin that assembles its own wire messages. ```python theme={null} from standin import MAX_EMOTION_CHARS MAX_EMOTION_CHARS # 40 ``` `expression()` raises `ValueError` on an empty emotion or one longer than `MAX_EMOTION_CHARS`. Everything in between is the service's to interpret. The cap is applied **where the message is built**, not at the edge of some plugin, and that placement is the point. An emotion reaches the avatar tile, and the string usually came from a model that whoever is on the call is steering: a tool argument, a token off a transcript. Bounding it at the builder means `session.express` is bounded, every plugin's own `express` tool is bounded, and no future one can forget to do it. ## Emotion with no extra model call An extra inference per sentence is not something a live call can pay for. `infer_emotion` reads the emotion out of the reply text instead, with no model call and no added latency: ```python theme={null} from standin import infer_emotion infer_emotion("Congratulations, that is excellent") # "happy" infer_emotion("Sorry, that is great news") # "sad" infer_emotion("They did what??") # "surprised" infer_emotion("the meeting is at four") # "neutral" ``` It returns `surprised`, `sad`, `happy` or `neutral`, ready to hand to `expression()`. First match wins in that order rather than a score being totalled: a startled "wow" must not be averaged away by a polite "thanks", and an apology must not be masked by an incidental "nice". `ExpressionCue` decides **when** one is worth sending, which is the harder half. Ask it on every assistant transcript, partial and final alike, and send whatever comes back: ```python theme={null} from standin import ExpressionCue cues = ExpressionCue() async def on_transcript(text: str, is_final: bool) -> None: emotion = cues.cue(text) if emotion: await session.express(emotion) ``` Two things are happening there. It **re-reads the emotion on every chunk**, so the face self-corrects as the rest of a sentence arrives: "let me check that" cues neutral, and "let me check that, no way!!" cues surprised a moment later. And it **returns `None` when nothing changed**, which is what stops a partial-per-word stream from sending dozens of identical messages for one sentence. Waiting for the final transcript instead would land the cue as the sentence ends, with the face stale for the entire time a happy or apologetic reply was being spoken. One instance per call. What it remembers is the last emotion that call was told to wear, readable as `cues.last_sent`. While a tool keeps the caller waiting, hold a thinking face: ```python theme={null} async def run_tool(name: str) -> str: emotion = cues.thinking(True) if emotion: await session.express(emotion) try: return await tools.run(name) finally: emotion = cues.thinking(False) if emotion: await session.express(emotion) ``` While thinking is on, `cue()` returns `None` for everything: without that suppression a transcript chunk arriving mid-tool makes the avatar look finished while it is still working. Only a transition acts, so setting the same state twice sends nothing and a retry around the tool is harmless. Leaving sends `neutral` when thinking is still the last thing the call was told to wear, because the model may say nothing at all after a tool result, and with no transcript to re-infer from the face would stick mid-thought for the rest of the call. Call it from a `finally` so a tool that raised still leaves the face behind it. The lexicon is **English only**, and that is a decision rather than an oversight. An Arabic reply, or a reply in any other language, infers `neutral`. Neutral is always a safe face, and a silent guess at the language would put a confident wrong one on the tile. Note the asymmetry before you go looking for a bug that is not there: the viseme table in the next sections **is** bilingual. The mouth moves on an Arabic reply. The face stays neutral. ## The viseme timeline ```python theme={null} from standin import SpeechMark SpeechMark # tuple[int, int]: (t_ms, viseme_id) ``` One mark is milliseconds from the start of the utterance, and which mouth shape to hold. `t_ms` counts from the start of **that turn's audio**, never from the moment the message reaches the service. This is the one place the two SDKs differ in shape. A mark is a **tuple** here, `tuple[int, int]`, and an **object** in TypeScript, where the same timeline is written `[{ tMs: 0, visemeId: 12 }, { tMs: 200, visemeId: 4 }]`. Everything else matches: the same ids, the same ordering rules, the same wire payload. Porting a plugin across rewrites the mark literals and nothing else. `session.send_speech_marks(marks)` sends the timeline for one utterance. `speech_marks(marks)` is the builder underneath, again exported for a plugin assembling its own wire messages. ```python theme={null} from standin import MAX_VISEME_ID MAX_VISEME_ID # 21 ``` Visemes use the Azure Speech numbering, 0 to 21, which is what the avatar expects. The builder **sorts the marks ascending and drops anything outside that range**, along with any mark timed before zero, rather than passing it on. The avatar reads the timeline in order, so a mark out of sequence or a shape it cannot render does not cost one frame: it desynchronises the mouth for the rest of the utterance. ## Measured beats still, guessed loses to still This is the rule that decides whether to send a timeline at all, and it is worth getting right before writing any of the code below. Send a timeline whenever it is spread over a duration you **measured**: the audio your worker actually sent for that turn, or real per-character timings from the speech provider. That is better than a still mouth, and on a realtime path it is the difference between an avatar that talks and one that never opens its mouth at all. Do not send a timeline spread over a duration **guessed** from text length or a words-per-minute rate. That is worse than sending nothing. The error in a guess compounds sentence after sentence, so the mouth drifts further from the voice the longer the call runs, and a mouth moving against the voice reads as broken in a way a still mouth never does. The distinction is the **duration**, not the phonemes. The mouth shapes are approximate either way. ## A timeline from the text and the audio you sent Every realtime speech-to-speech model streams voice and hands back no phoneme timings, so there is nothing for `send_speech_marks` to carry and the mouth never moves on the default path. `TurnLipSync` closes that gap the one honest way open to a worker: it counts the audio the turn actually sent, byte by byte, and spreads the text over that measured length. ```python theme={null} from standin import TurnLipSync lipsync = TurnLipSync() async for chunk in model_audio: # the audio sink await session.send_audio(chunk) lipsync.audio_sent(chunk) async def on_transcript(text: str, is_final: bool) -> None: if is_final: # once per turn, never on a partial marks = lipsync.finish(text) if marks: await session.send_speech_marks(marks) async def on_barge_in() -> None: lipsync.cancel() ``` `audio_sent(pcm)` takes a PCM16 mono buffer. The constructor's `sample_rate_hz` defaults to the wire's own 16 kHz, which is what a plugin sending frames to the call is holding; pass the real rate when your sink counts at another one, as in `TurnLipSync(sample_rate_hz=24_000)`. It is keyword only, so there is no positional form to get wrong. For a sink that hands over encoded audio, `audio_sent_ms(ms)` adds a duration directly, and it ignores a chunk that measures as zero, as negative, or as no number at all rather than taking the turn's count with it. Each chunk is rounded as it is added rather than kept as a running float, so both SDKs accumulate the same integer for the same stream of chunks. `finish(text)` returns the timeline and resets the counter, so the next turn starts from zero. It returns an empty list when no audio was sent or the text carries no mouth shape, and you send nothing in that case. Emit once per turn, on the final transcript: a partial would send an ever-lengthening timeline several times over and the avatar would restart the mouth mid-sentence. `cancel()` is not optional. On a barge-in the service drops audio the caller never heard, and a counter that keeps those milliseconds spreads the next turn's text over its own audio **plus the discarded audio**. The mouth runs long for the whole of that turn and every turn after it. Call it wherever you call [`cancel_playback()`](/python-sdk/call-handler#barge-in-and-cancel-playback). Underneath it, and usable on their own: | Name | What it is | | ------------------------------------- | -------------------------------------------------------------------------- | | `estimate_visemes(text, duration_ms)` | The spread itself. Returns `(t_ms, viseme_id)` marks, strictly increasing. | | `viseme_for_char(ch)` | The mouth shape a character wears, or `None`. | | `CHAR_VISEMES` | The character to shape map, read-only. | | `SILENCE_VISEME` | `0`, a closed mouth, and what the space between two words becomes. | `estimate_visemes` returns an empty list rather than raising on anything it cannot use: no text, a duration that is zero, negative, infinite or not a number, or text with no mouth shape in it at all. `"3.5%"` is the last case, and an empty answer is the right one: mapping digits and punctuation to silence instead would punch a hole of closed-mouth frames into the middle of a spoken number. Only a space becomes silence. A run of one shape is one mark, timed at the first character of the run, because "mmm" is one mouth position and a mark per character would multiply the payload for an identical rendering. Times come back strictly increasing, and that is not cosmetic either. Spread a long sentence over a very short buffer and the step falls below half a millisecond, so neighbouring marks round onto the same one. The later shape wins, because a shape held for zero milliseconds is not renderable, and because `speech_marks` re-sorts by `(t_ms, viseme_id)` and would otherwise hand the avatar a different winner than the one the walk ended on. `CHAR_VISEMES` covers **all 26 Latin letters and all 28 Arabic letters** in one map, with the eight Arabic variant forms and the three short vowels alongside them. That is not a nice-to-have. Without the Arabic rows an Arabic reply produces no tokens, carries no timeline, and the mouth simply does not move for half the people who will be on these calls. The stretch and doubling marks are deliberately absent, because they carry no mouth shape of their own and mapping them would insert mouth changes nobody spoke. `viseme_for_char` lowercases and looks the character up as written, with **no Unicode normalization at all**. Folding presentation forms onto their base letter would change which characters map, and the two SDKs would then disagree about the same string. The map is read-only and a write to it raises. Both SDKs agree on it byte for byte, and a caller that could mutate it would make one call's mouth disagree with every other call's. ## Real timings when your provider has them When the speech provider returns per-character timings, use them. They are strictly better than an estimate and they cost nothing, since the synthesis call already produced them: ```python theme={null} from standin import estimate_visemes, visemes_from_alignment marks = visemes_from_alignment(result.characters, result.character_start_times_seconds) if not marks: marks = estimate_visemes(text, lipsync.duration_ms) ``` Core takes two plain sequences: the characters as the provider spoke them, and when each one starts in seconds. Normalising a vendor's field names is the speech plugin's job, not this function's. It is deliberately forgiving of the shapes providers actually return. Ragged sequences are walked to the shorter of the two, a timing that is not a finite number costs its own mark and nothing else, and a time before the start of the utterance is clamped to zero. Providers do return mismatched lengths and bare `NaN` values, and throwing there would lose the turn over a cosmetic hint. Fall back to `estimate_visemes` on an **empty result**, as above, rather than on a missing alignment. A provider that returned timings for punctuation only has an alignment and still needs the estimate. ## When to send the timeline Where the duration is known before playback, which is a text-to-speech path, send the timeline **ahead of the first audio frame**. The service then has the whole mouth plan before the voice starts. On a realtime path the duration is only known once the turn ends, so the marks necessarily go out **after the last chunk was handed over**. That is correct while the service still holds that turn's audio buffered for playout, which is the normal case, and it is why the marks are worth sending at all on a path that would otherwise never move the mouth. ## Your own video on the tile When your agent already produces video of its own (an avatar worker, a rendered face, a camera), that video can go on the tile instead of StandIn's own avatar. `session.send_tile_frame(jpeg, width, height)` puts one frame there. The SDK owns the sequence number and stamps the frame with the **outbound audio timeline**, `session.media_time_ms`, the same clock the voice is stamped on, so the two streams cannot disagree about what time it is. A wall clock keeps ticking through listening silence while the audio clock does not, so a wall-clock stamp would make the two look like they are drifting apart when they are in step. Sending frames is the easy part. Sending them at a rate that does not hurt the call is what `TileStream` owns: ```python theme={null} from standin import TileStream tile = TileStream(session) await tile.start() ... tile.offer_rgb(rgb_bytes, width, height) # as often as you like ... await tile.aclose() ``` | Constructor argument | Default | Meaning | | -------------------- | ------------ | ------------------------------------------------------------- | | `session` | *(required)* | The `CallSession` the frames go to. | | `fps` | `12` | Clamped to 1 through `MAX_TILE_FPS`. | | `encoder` | `None` | Yours, or Pillow is found for you. | | `max_buffered_bytes` | `327680` | The backpressure budget, in bytes of unflushed outbound data. | ```python theme={null} from standin import MAX_TILE_FPS, TILE_HEIGHT, TILE_WIDTH MAX_TILE_FPS # 20 TILE_WIDTH, TILE_HEIGHT # 640, 360 ``` Frames are encoded to 640 by 360, because shipping an avatar's native resolution only spends bandwidth on pixels the tile will not show. `MAX_TILE_FPS` is a sender-side sanity clamp rather than a protocol limit: a talking head gains nothing above 20, and a higher rate only spends local CPU on encoding and base64. **Latest wins, and each frame goes at most once.** Frames are offered into a single slot, never a queue. A ticker takes whatever is newest and sends it. So a source producing faster than the wire drops the middle frames rather than falling behind, and a source that stops producing goes quiet rather than repeating one stale frame forever. Silence is how a stream ends. That is right for video and would be wrong for audio. A dropped frame is a frame the caller never notices missing. A dropped audio buffer is a hole in a sentence, which is why the audio path buffers what it cannot send yet and this path throws it away. **Video yields to voice.** `TileStream` watches `session.buffered_bytes` and drops a frame rather than adding to it once the budget is exceeded, and it re-checks after an encode returns, because audio may have filled the socket while the encoder was off the loop. The default budget of 320 KiB is deliberately tighter than the audio buffer cap of `MAX_AUDIO_BUFFER_BYTES`. Both streams share one socket, and a caller forgives a dropped frame far more readily than a break in the voice, so a loaded path goes quiet promptly rather than building up seconds of skew. `frames_sent` and `frames_dropped` are both readable, and a healthy call has some drops. `offer_rgb` and `offer_jpeg` never block and never raise. They are meant to be called from a decode loop that must not be slowed down by the wire. `offer_jpeg(jpeg)` skips the encoder entirely, for a source that hands you JPEG already: nothing is re-encoded, no encoder needs to be installed at all, and the width and height default to the tile's own 640 by 360. `aclose()` is safe to call twice and on every teardown path. ### The encoder Encoding packed RGB to JPEG needs Pillow, which is an optional extra: ```bash theme={null} pip install "standin-sdk[tile]" ``` Pass your own encoder to skip it. It is any callable taking packed RGB bytes plus a width and a height and returning JPEG bytes, synchronously: it is run in a worker thread rather than on the event loop, so a slow encoder costs frames and never the voice. The TypeScript twin's encoder returns a promise instead. `jpeg_encoder()` is what `start()` calls when you pass none: it returns Pillow's encoder, or `None` having logged why. Without Pillow and without an encoder of your own, the tile relay stays off with one line in the log and **the call is unaffected**. The caller hears your agent and sees StandIn's rendered avatar, which is what they would have seen anyway. Pillow is optional precisely because most deployments never put their own video on the tile, and a dependency in every install is the wrong trade for a feature a minority uses. Note that `offer_jpeg` still works in that state: it is only `offer_rgb` that needs an encoder. ## Next The other half of the video lane: what the caller shows your agent. Giving a model the `express` tool, and the rest of the built-in surface. # Call handler Source: https://docs.komaa.com/python-sdk/call-handler The five optional methods a StandIn Python plugin implements, the CallSession surface, and barge-in with cancel_playback. The handler is the whole contract between the SDK and your agent, and it is deliberately five methods wide. `CallServer` owns everything that is the same for every framework. A handler owns only what differs: what to do with a caller's voice, and where the reply comes from. ```python theme={null} from standin import CallServer, CallSession class MyHandler: async def on_start(self, session: CallSession) -> None: ... async def on_caller_audio(self, pcm: bytes) -> None: ... async def on_context(self, text: str) -> None: ... async def on_goodbye(self, text: str) -> None: ... async def aclose(self, reason: str) -> None: ... server = CallServer(handler_factory=MyHandler) ``` `CallHandler` is a `typing.Protocol`. Nothing inherits from anything, every method is optional, and a missing method is a no-op, so a handler that only wants audio implements only `on_caller_audio`. A synchronous method is accepted too: a callback with nothing to await should not be forced to declare `async`. `handler_factory` is called with no arguments and builds **one handler per call**, so per-call state can live on `self` and configuration is closed over rather than threaded through the server. ## Ordering and failure Three rules govern when your methods are called, and all three are the same in both SDKs. * **Nothing arrives before `on_start` returns.** Inbound messages are handled serially, so caller audio packed into the same read as `session.start` waits for your `on_start`. A slow start delays the caller rather than dropping frames. * **Context that arrives before the handler exists is dropped.** The handler is built when `session.start` lands, and every dispatch is guarded on it existing. On a fast dial a participants or recording sentence can beat that frame, and there is nobody to give it to. * **An exception ends that call alone.** It is logged and the call closes with the reason `handler-failure`. One bad call must never take the worker with it. The server does not queue on your behalf **after** `on_start` either, because what "ready" means is a framework's own business. A handler whose provider socket opens inside `on_start` still gets caller audio and context the moment that returns, which is what [`StartupBuffer`](/python-sdk/realtime-providers#what-arrives-before-your-agent-is-ready) is for: a bounded holder for the caller's first words and the first context sentence, with the oldest dropped first. ## CallSession `on_start` receives the session, and it is the handler's only way to reach the socket. It is valid until the call ends. Seventeen members, and this is all of them. | Member | What it is | | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `session.call_id` | StandIn's id for this call. Authenticated: it is the value the handshake HMAC signed, not something a caller supplied. | | `session.start` | The `session.start` that opened the call: `call_id`, `thread_id`, `caller`, `direction`, `recording_status`, `tenant_id`. | | `await session.send_audio(pcm)` | Send the agent's voice to the caller. PCM16, 16 kHz, mono, little-endian. | | `await session.cancel_playback()` | Drop whatever agent audio StandIn still has buffered. See [Barge-in](#barge-in-and-cancel-playback). | | `await session.end(reason)` | End the call. Idempotent, and the first reason wins. | | `session.recording_active` | Whether the call is being recorded, right now. | | `session.speaker` | Who is speaking, when StandIn sends unmixed audio. `None` on the mixed path. | | `session.participant_count` | How many people are on the call. Zero until StandIn says. | | `session.answered` | Whether anything has actually taken this call yet. | | `session.mark_answered()` | Say that an agent has taken the call. Synchronous, stamped once, never re-stamped. Sending audio already counts, so most plugins never call it. | | `session.buffered_bytes` | How much outbound data the socket has not flushed. The number a continuous sender watches before deciding to drop a frame. It reads `0` when the transport cannot report it, so treat a zero as no evidence of backpressure rather than proof of an idle socket. | | `session.media_time_ms` | The outbound audio timeline, in milliseconds. The clock a video frame must be stamped with, because a wall clock keeps ticking through listening silence and this one does not. | | `session.latest_video_frame(source)` | The most recent frame the caller showed, or `None`. Synchronous: it reads what the frame loop already stored. See [Vision](/python-sdk/vision). | | `await session.send_tile_frame(jpeg, width, height)` | One frame of continuous video on the bot's tile. See [The avatar](/python-sdk/avatar). | | `await session.display_image(image, mime, duration_ms, mode, caption)` | Draw an image on the bot's tile for a few seconds. See [Vision](/python-sdk/vision). | | `await session.express(emotion)` | Hint the emotion the avatar wears. See [The avatar](/python-sdk/avatar). | | `await session.send_speech_marks(marks)` | The viseme timeline for one utterance, which is what drives lip-sync. See [The avatar](/python-sdk/avatar). | `recording_active` is one flag the server keeps current, so no plugin re-derives it from the context sentence it happened to see. A reported `recording.status` **wins over the `session.start` snapshot**, whichever arrives first: the snapshot omits the field when the state was unknown at answer time, and an omitted field is not "not recording". Gate on `recording_active` before anything that STORES what the caller said or showed with a third party. A recorded call is one the caller was told is being kept; an unrecorded one is not. ### What session.start carries Both are frozen dataclasses, and both are on the barrel for a handler that wants to annotate them: `from standin import Caller, SessionStart`. ```python theme={null} class SessionStart: call_id: str thread_id: str caller: Caller direction: str = "inbound" # "inbound" or "outbound" recording_status: str | None = None tenant_id: str | None = None class Caller: aad_id: str | None = None display_name: str | None = None tenant_id: str | None = None ``` Blank or absent values normalize to `None`, so treat every caller field as optional. A `direction` that is neither `inbound` nor `outbound` reads as `inbound` rather than being carried through as something a branch has to handle. `caller.aad_id` is `None` for guest and anonymous callers, which means an anonymous caller can never match an allowlist. That is the intended behaviour, not a gap. Never use it as a bare key for per-caller memory without checking it first either, or two anonymous callers share one identity. The server owns the outbound sequence number and the audio timeline, so a handler that swaps or re-publishes its audio source cannot make timestamps jump backwards. You never compute a `seq` and you never build a frame. ### Answering, and the plugin that has to say so The server runs a stale-call reaper: 120 seconds a call may run with **nothing having answered it**, after which it ends as `no-agent-answered`. It is the gap none of the other watchdogs cover, because `session.start` arrived, `on_start` succeeded, and the caller is still talking the whole time. Sending audio is answering, so a handler that speaks is covered without doing anything. A handler that answers by a route the server cannot see, joining a room and letting an agent speak there, has to say so: ```python theme={null} async def on_agent_track_published(self) -> None: self._call.mark_answered() # the agent's OWN track, not a participant joining ``` Call it when the agent's own audio track appears, not when a participant connects. Monitors, recorders and avatar workers all connect, and none of them is an agent answering. See [the stale-call reaper](/python-sdk/call-server#the-stale-call-reaper) for the timer itself. ### send\_audio can drop what you hand it Past 1 MB of unflushed transport buffer (`MAX_AUDIO_BUFFER_BYTES`), agent audio is **shed rather than queued**: `send_audio` advances the timeline and returns without sending, and logs at most once every five seconds. The caller hears a gap rather than the call wedging, and the timeline still advances because a dropped frame is a gap in what they hear, not a rewind. Control frames are never shed. A call that cannot be ended is the failure this exists to prevent. `session.buffered_bytes` is how you see it coming before a continuous stream starts losing frames. See [Backpressure](/python-sdk/call-server#backpressure). *** ## on\_start ```python theme={null} async def on_start(self, session: CallSession) -> None: self._call = session self._client = await open_realtime_socket() ``` The call is live. Join a room, open a realtime socket, build an agent, whatever this framework needs. Audio does not flow until this returns, so a slow start delays the caller rather than dropping frames. That is also why it is bounded: `on_start_timeout` defaults to 15 seconds, and a handler that exceeds it loses the call with the reason `handler-start-timeout`. A failure inside it closes as `handler-start-failure`, not `transport-failure`, so a provider outage in your plugin is never reported as StandIn's own socket failing. ### Refusing a call `session.end()` is safe to call from inside `on_start`. It asks for the close and returns immediately, because awaiting teardown from inside `on_start` would deadlock: teardown waits for `on_start` to return before dispatching your `aclose`. ```python theme={null} async def on_start(self, session: CallSession) -> None: caller = session.start.caller if caller.aad_id not in self.allowlist: await session.end("caller-not-allowed") return self._call = session ``` Return straight after. Teardown waits, briefly and boundedly, for `on_start` to unwind before it dispatches `aclose`, so anything you build after the refusal still gets closed. *** ## on\_caller\_audio ```python theme={null} async def on_caller_audio(self, pcm: bytes) -> None: await self._client.append_input(pcm) ``` One frame of the caller's voice: PCM16, 16 kHz, mono, little-endian. Nominally 20 ms, which is 640 bytes, but the only invariant is an even byte count: measure with `frame_duration_ms(pcm)` rather than counting frames, and never index into a caller frame at a fixed offset. See [Inbound audio](/python-sdk/audio#inbound-audio). This runs on the receive path of a live call, so it must not block. Hand the bytes to your provider and return. The frame is already validated, so a truncated or malformed payload is dropped by the server and never reaches here. A live Microsoft Teams call delivers PCM continuously, silence included. If the frames stop, the call is gone on the far side, and the idle watchdog will end it. *** ## on\_context ```python theme={null} async def on_context(self, text: str) -> None: await self._agent.add_system_note(text) ``` Non-interrupting context about the call, already written as a plain sentence ready to put in front of a model. Three things arrive this way: * participant counts and group-call etiquette, for example `"There are 4 human participants on this call. Stay quiet unless directly addressed."` * DTMF key presses, for example `'The caller pressed the "5" key on their keypad.'` * recording status changes, for example `"The Microsoft Teams call recording is now ACTIVE."` It is delivered as it arrives. The server does not queue on your behalf, because what "ready" means is a framework's own business: a framework that cannot accept context before its agent exists should queue it here. The LiveKit plugin does exactly that, because a LiveKit data packet reaches only the participants connected at that instant. *** ## on\_goodbye ```python theme={null} async def on_goodbye(self, text: str) -> None: await self._client.interrupt_and_say(text) ``` StandIn is ending the call and wants this line spoken first. Interrupt the current turn and say it. The SDK has already told StandIn to drop whatever agent audio it had buffered, so the line plays immediately, and teardown follows within seconds. A goodbye queued behind a long answer is a goodbye the caller never hears. If your provider guards response creation on "is a response already active", a plain `say` will be swallowed here, because the goodbye almost always arrives mid-answer. Cancel the active response first, then speak. *** ## aclose ```python theme={null} async def aclose(self, reason: str) -> None: await self._client.close() ``` Release everything this call holds. Always called exactly once, on every path including cancellation, before the slot is freed. It runs **before** the socket closes, so a handler that wants to say something on the way out still can. `reason` is the first close cause recorded for the call, not the last. | Reason | Who produced it | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `call-ended` | StandIn sent `session.end`, or nothing named a cause. | | `caller-idle-timeout` | The caller-audio idle watchdog: the frames stopped arriving. | | `pre-start-timeout` | The socket authenticated and never sent `session.start`. | | `handler-start-timeout` | Your `on_start` ran past `on_start_timeout`. | | `handler-start-failure` | Your `on_start` raised. | | `handler-failure` | Any other handler method raised. | | `no-agent-answered` | The stale-call reaper: 120 seconds with nothing having answered the call. | | `call-duration-limit` | The call-duration ceiling, **after** `on_goodbye` has already fired and been given its grace. | | `agent-ended-call` | The model called the built-in `end_call` tool. | | `outbound-no-answer` | An outbound leg rang out. So does `outbound-expired`, when a parked message could not be matched to the call that was placed for it. Both come from [Reaching people](/python-sdk/reaching-people). | | `transport-failure` | The socket itself failed. | | `server-shutdown` | `server.aclose()`, which ends every live call with this reason rather than waiting for it to finish. | Anything you pass to `session.end(...)` yourself arrives here too, which is how `caller-not-allowed` in the refusal above reaches your own cleanup. `call-duration-limit` is the one that needs planning for: by the time it arrives your handler has already been asked to say the goodbye line, so treat `on_goodbye` as the last chance to speak rather than `aclose`. *** ## The optional callbacks Two more the server duck-types. Most handlers implement neither. `on_speaker_change(name)` fires when a different person starts speaking, and only when StandIn sends unmixed audio. It is called on CHANGE only, never per frame: the name rides every inbound audio frame, and a model told forty times a second who is speaking would hear nothing else. `on_video_frame(frame)` gives you every sampled frame of the caller's camera or screen share. Reach for `session.latest_video_frame()` instead when the model only looks on demand. Neither is on `CallHandler`, which is a runtime-checkable Protocol: a member there would make `isinstance(handler, CallHandler)` fail for every handler written before the callback existed, while the documented rule says each method is optional. Implementing the method is enough, and inheriting from `SpeakerHandler` or `VideoHandler` is never required. ## Barge-in and cancel playback `cancel_playback()` is the only lever that un-sends audio already handed to the service. It flushes the platform player, so the caller stops hearing the turn they just interrupted. Without it, a barge-in stops the **model** but the bot keeps talking for the length of the buffered PCM. The caller hears themselves interrupt and then hears the agent carry on for several seconds, which reads as the agent ignoring them. Call it the moment your provider reports that the caller started speaking, and call it **before** you cancel the response upstream. The buffered audio is the part the caller can hear, so it goes first. ```python theme={null} async def on_speech_started(self) -> None: await self._call.cancel_playback() # first: silence what is already queued await self._client.cancel_response() # then: stop the model generating more self._aligner.reset() # and drop the partial frame of the dead turn ``` Three things, in that order. `FrameAligner.reset()` matters because the bytes it is holding back belong to the turn the caller just interrupted: flushing them instead would replay a fragment of the abandoned answer after the silence. See [Audio](/python-sdk/audio#reset-on-barge-in). `cancel_playback()` is cheap and safe to call when nothing is playing. Calling it on every detected speech start is the right default. *** ## A full handler A realtime speech-to-speech handler, with the resampling, the frame alignment and the barge-in in place. `RealtimeClient` here stands in for whatever provider you use. ```python theme={null} import asyncio from standin import ( REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ, CallServer, CallSession, FrameAligner, resample_pcm16, ) class RealtimeHandler: """One Microsoft Teams call, bridged to one realtime model session.""" def __init__(self, api_key: str, instructions: str) -> None: # One handler per call, so per-call state is safe on self. self._api_key = api_key self._instructions = instructions self._call: CallSession | None = None self._client: RealtimeClient | None = None # The model speaks 24 kHz and chunks where it likes; the wire wants # whole 640-byte frames at 16 kHz. self._aligner = FrameAligner() async def on_start(self, session: CallSession) -> None: caller = session.start.caller # No leading truthiness test on aad_id. Microsoft Teams reports no AAD object id # for guest and anonymous callers, so `if caller.aad_id and ...` would # fail OPEN for exactly the callers you least want to admit. if caller.aad_id not in ALLOWED: # Safe from inside on_start: it asks for the close and returns. await session.end("caller-not-allowed") return self._call = session self._client = await RealtimeClient.connect( api_key=self._api_key, instructions=( f"{self._instructions} You are on a Microsoft Teams call " f"with {caller.display_name or 'a caller'}." ), on_audio=self._on_model_audio, on_speech_started=self._on_barge_in, on_turn_done=self._on_turn_done, ) async def on_caller_audio(self, pcm: bytes) -> None: # Receive path: hand it over and return. Never block here. client = self._client if client is not None: await client.append_input( resample_pcm16(pcm, SAMPLE_RATE_HZ, REALTIME_SAMPLE_RATE_HZ) ) async def on_context(self, text: str) -> None: client = self._client if client is not None: # Non-interrupting: it informs the next turn, it does not start one. await client.add_context(text) async def on_goodbye(self, text: str) -> None: client = self._client if client is not None: # Cancel first, then speak: a plain say is swallowed by a provider # that guards on "is a response already active", and this line # almost always arrives mid-answer. await client.interrupt_and_say(text) async def aclose(self, reason: str) -> None: client, self._client = self._client, None if client is not None: await client.close() # ---- provider callbacks ------------------------------------------------ async def _on_model_audio(self, pcm_24k: bytes) -> None: call = self._call if call is None: return pcm = resample_pcm16(pcm_24k, REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ) # Whole frames only; the remainder is carried, not dropped. Dropping it # clips a few milliseconds off every chunk seam, which is audible. for frame in self._aligner.push(pcm): await call.send_audio(frame) async def _on_turn_done(self) -> None: call = self._call tail = self._aligner.flush() # zero-padded residual if call is not None and tail is not None: await call.send_audio(tail) async def _on_barge_in(self) -> None: call = self._call if call is None: return await call.cancel_playback() # un-send what StandIn still holds if self._client is not None: await self._client.cancel_response() self._aligner.reset() # the residual belongs to a dead turn async def serve() -> None: server = CallServer(handler_factory=lambda: RealtimeHandler(API_KEY, PROMPT)) await server.start() try: await asyncio.Event().wait() finally: await server.aclose() ``` ## Next Every constructor option, and what each one defends against. Why the resampler and the aligner are in the SDK rather than in your plugin. # CallServer Source: https://docs.komaa.com/python-sdk/call-server Every CallServer constructor option in the StandIn Python SDK, and the failure each one defends against. `CallServer` answers the socket StandIn dials, authenticates it, speaks the wire protocol, and hands each call to a handler your plugin supplies. Everything it owns is the same whichever agent framework is on the other side, which is exactly why it lives in the SDK and not in the plugins. ```python theme={null} from standin import CallServer server = CallServer(handler_factory=MyHandler) await server.start() ... await server.aclose() ``` ## What it owns * the HMAC handshake, its freshness window and its single-use replay guard * capacity, draining, and the one-live-session-per-`callId` rule * the frame loop: inbound `session.start`, `audio.frame`, `video.frame`, `ping`, `participants`, `dtmf`, `recording.status`, `assistant.say` and `session.end`, and outbound `audio.frame`, `assistant.cancel`, `expression`, `speech.marks`, `display.image`, `display.frame`, `pong` and `session.end` * outbound sequence numbers and the audio timeline, for the tile as well as the voice * five timers: the pre-start watchdog, the caller-audio idle watchdog, the `on_start` timeout, the stale-call reaper and the optional call-duration ceiling * backpressure on outbound audio, so a peer that stops reading cannot wedge the call * `GET /healthz`, the per-call upgrade route, and the outcome route when you ask for one * idempotent, shielded teardown that always frees the slot A frame type the SDK does not recognise is ignored by contract, so an older plugin and a newer StandIn interoperate. That rule is about unknown frames. The avatar and display messages are not among them: they are messages this server builds and sends, and `video.frame` is parsed and stored on the way in. The server keeps the latest video frame per source whether or not your handler implements `on_video_frame`, because a plugin that only looks when the model asks implements no callback at all. ## Constructor ```python theme={null} CallServer( *, handler_factory: HandlerFactory, secret: str | None = None, host: str | None = None, port: int | None = None, ws_path: str | None = None, max_connections: int = 64, pre_start_timeout: float = 10.0, audio_idle_timeout: float = 45.0, on_start_timeout: float = 15.0, max_call_seconds: float = 0.0, stale_call_reaper_seconds: float = 120.0, goodbye_text: str = "We are out of time on this call, so I have to stop here. Goodbye.", goodbye_grace: float = 6.0, on_call_outcome: Callable[[str, str], Any] | None = None, ) ``` All arguments are keyword-only. Every timer is in **seconds**; the TypeScript twin spells the same options in milliseconds. | Option | Default | Environment fallback | | --------------------------- | --------------------------------------------------------------------- | -------------------- | | `handler_factory` | *(required)* | none | | `secret` | *(required)* | `STANDIN_SECRET` | | `host` | `0.0.0.0` | `STANDIN_HOST` | | `port` | `9442` | `STANDIN_PORT` | | `ws_path` | `/msteams/calling` | `STANDIN_WS_PATH` | | `max_connections` | `64` | none | | `pre_start_timeout` | `10.0` | none | | `audio_idle_timeout` | `45.0` | none | | `on_start_timeout` | `15.0` | none | | `max_call_seconds` | `0.0`, off | none | | `stale_call_reaper_seconds` | `120.0`, **on** | none | | `goodbye_text` | `"We are out of time on this call, so I have to stop here. Goodbye."` | none | | `goodbye_grace` | `6.0` | none | | `on_call_outcome` | `None` | none | ### handler\_factory Builds one handler per call, called with no arguments. This is the plugin. A non-callable raises `StandInError` at construction, not at the first call, because a caller is already on the line by then. ```python theme={null} def build_handler() -> MyHandler: return MyHandler(api_key=API_KEY) # closes over its own configuration server = CallServer(handler_factory=build_handler) ``` ### secret The connection secret from the StandIn portal. It must byte-match, or the handshake is rejected with 401. Falls back to `STANDIN_SECRET`, and an empty secret raises `StandInError` rather than starting a listener that would accept nothing. ### host, port, ws\_path Where the listener binds. The defaults match the StandIn plugin layout, `0.0.0.0:9442` at `/msteams/calling`, because the worker usually runs in a container behind an ingress. Bind `127.0.0.1` when only a local tunnel should reach the listener. The upgrade is HMAC-authenticated either way. `ws_path` is normalized to a single leading slash with no trailing slash. A path that reduces to `/` raises `StandInError`: the route has to have a real path, because the per-call `callId` is appended to it as `{ws_path}/{callId}`. The server also serves `GET /healthz`, which returns `{"ok": true, "calls": }`. ### max\_connections Concurrent live calls. Checked **before any crypto runs**, so a flood cannot make the worker spend CPU verifying signatures for calls it was never going to accept. Over the limit, the upgrade gets `503 at capacity`. ### pre\_start\_timeout Seconds a socket may stay silent after authenticating before it is dropped for never sending `session.start`. This defends against a slot leak: a socket that authenticates and then says nothing holds a connection slot and a `callId` that nothing will ever free, so every retry for that call would get `409` forever. The close reason is `pre-start-timeout`. It bounds the arrival of `session.start` only. Once that frame is received, `on_start_timeout` owns the startup budget. ### audio\_idle\_timeout Seconds without caller audio before a live call is declared dead and torn down. `0` disables it. A live Microsoft Teams call delivers PCM continuously, and silence is still frames, so audio going quiet for this long means the call is gone on the far side and nobody said so. That happens: the peer keeps the socket open, and can even keep pinging, while its own teardown is wedged. Without this backstop the handler's session burns until someone notices. The close reason is `caller-idle-timeout`. The watchdog is armed **before** `on_start` runs, not after, so a hung `on_start` is covered by it too. An outbound call that nobody has picked up yet is skipped, because a ringing call carries no caller audio by definition and would otherwise look exactly like a dead one. ### on\_start\_timeout Seconds a handler's `on_start` may take before the call is given up. `0` disables it. `on_start` does real network work: LiveKit joins a room and dispatches an agent, a realtime plugin opens a provider socket. The frame loop is suspended while it runs, so `session.end` is not even read, and an unbounded `on_start` holds a slot for the life of the worker. Exceeding it closes the call as `handler-start-timeout`; raising inside it closes as `handler-start-failure`. Those two reasons are distinct from `transport-failure` on purpose. Reporting a plugin's problem as StandIn's own socket failing sends both sides debugging the wrong system. ## The stale-call reaper `stale_call_reaper_seconds` is 120 seconds a call may run with nothing having answered it. Set `0` to disable it. Four of the five bounds on this page are on by default; `max_call_seconds` is the one that is not. It covers the gap none of the other timers do. `pre_start_timeout` was watching for a `session.start` that arrived. `on_start_timeout` bounded an `on_start` that succeeded. `audio_idle_timeout` is satisfied, because the caller is still talking. So StandIn is on the call, the caller hears nothing, and no other timer will ever fire. An agent dispatch that never lands looks exactly like this, and it is the commonest misconfiguration there is. The close reason is `no-agent-answered`. The clock starts when the socket connects, and the check is coarse on purpose: this is a grace period, not something anybody measures to the second. **Sending audio counts as answering**, so most plugins are covered without doing anything. A plugin that answers by a route this server cannot see, joining a room and letting an agent speak there, calls [`session.mark_answered()`](/python-sdk/call-handler#callsession) when the agent's own audio track appears. Documenting the reaper without that method would be describing a timer with no way to satisfy it. Call `mark_answered()` when the **agent's** track appears, not when a participant connects. Monitors, recorders and avatar workers all connect, and none of them is an agent answering. ## The call-duration ceiling `max_call_seconds` is a hard ceiling on one call, measured from `session.start`. It is off by default, because a ceiling is a policy rather than a safety net. It is different from `audio_idle_timeout`, and the difference is the whole point. That watchdog ends a call that went **quiet**. This one ends a call that has not: a caller who will not hang up, a model looping at itself, an automated system that dialled and never stopped talking. Each of those bills a provider by the minute for as long as the socket lives, and none of them trips a silence check. When the ceiling is reached the server flushes playback first, then hands `goodbye_text` to your handler's `on_goodbye`, the same callback StandIn's own closing line uses, so a plugin needs no new code to honour it. `goodbye_grace` bounds how long that line gets: after it the call ends whether or not anything was said, because a plugin that hangs in `on_goodbye` must not turn a time-limited call into an endless one. The close reason is `call-duration-limit`, and it arrives **after** `on_goodbye` has already fired. ```python theme={null} server = CallServer( handler_factory=MyHandler, max_call_seconds=15 * 60, goodbye_text="We are at the end of our time, so I will stop here. Goodbye.", ) ``` The flush has to come first, or the goodbye queues behind however many seconds of agent audio the service still holds and the call ends before anyone hears it. ## Outbound call outcomes `on_call_outcome` is called with `(call_id, outcome)` when StandIn reports how an outbound call ended without anyone answering. It is the only signal that nobody picked up, and without it an unanswered call waits out the ring timer before anything can be said about it. ```python theme={null} server = CallServer(handler_factory=MyHandler, on_call_outcome=lane.on_outcome) ``` Passing it is what **creates the route**: `POST {ws_path}/outcome/{callId}`. Leave it unset and a POST there is a 404, so a worker that never places a call opens no extra surface. The route sits under `ws_path`, so the `/msteams/calling` mount you already have covers it and there is no second tunnel command to run. See [Expose your agent](/expose#mount-the-call-path). Four things about the verification are worth knowing: * It is signed with `X-StandIn-Signature-V2` over the method, the path and a hash of the body, so a signature captured for one route is useless against another. * The body is read **before** the signature can be checked, because the signature covers a hash of it. That is why the body is capped at 8 KB first: the cap is the bound on what an unauthenticated peer can make the worker read. * Freshness is checked in the 60-second handshake window, not the longer body window. * Over the cap is `413`, a bad signature or a stale timestamp is `401`, and a good one is `204`. A plugin that raises inside the callback is logged and the request is still acknowledged, because a failing outcome handler must not turn into a retry loop. Your `OutboundLane` reads this to sweep an unanswered call. See [Reaching people](/python-sdk/reaching-people). ## Backpressure Outbound audio is **shed, not queued**. When the transport has more than `MAX_AUDIO_BUFFER_BYTES` (1 MB) unflushed, `send_audio` advances the sequence number and the timeline and returns without sending, and logs at most once every five seconds. ```python theme={null} from standin import MAX_AUDIO_BUFFER_BYTES # 1048576 ``` Two decisions in that sentence are load-bearing: **The timeline advances anyway.** It is the caller's clock. A dropped frame is a gap in what they hear, not a rewind, and stalling the clock would make every later frame claim a time that has already passed. **Audio only.** Control frames are never shed, because control frames are what end a call, and a call that cannot be ended is the failure this exists to prevent. A slow or wedged peer otherwise turns "await every send" into an unbounded queue, and those awaits are what stall the provider receive loop feeding them. Shedding keeps the loop moving: the caller hears a gap rather than the call wedging. Watch [`session.buffered_bytes`](/python-sdk/call-handler#callsession) before pushing a continuous stream. The avatar tile has its own, tighter budget, because a caller forgives a dropped frame far more readily than a break in the voice: see [The avatar](/python-sdk/avatar). ## Properties | Property | Meaning | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `server.ws_path` | The normalized path the listener answers on. | | `server.active_calls` | Live calls right now. | | `server.running` | Whether the listener is actually bound. A host that wires its own connect and disconnect needs this to tell a live listener from a dead one, rather than reporting a worker as connected because something once called `start()`. | | `server.host` | The interface the listener is bound to, or will be. | | `server.port` | The port actually bound, or the configured one before `start()`. Ask for it after starting on port `0`, which is how an ephemeral listener avoids racing another process for a port picked in advance. | | `server.draining` | Set `True` to refuse new calls while live ones continue. | | `server.audio_idle_timeout` | Readable and writable after construction. | | `server.on_start_timeout` | Readable and writable after construction. | | `server.max_call_seconds` | Readable and writable after construction. | | `server.goodbye_text` | Readable and writable after construction. | | `server.goodbye_grace` | Readable and writable after construction. | | `server.stale_call_reaper_seconds` | Read after construction, and applied when `start()` arms the reaper. | ## Logging The SDK logs through the standard library, on the logger named `standin`. There is no `set_logger` function to call: routing it is whatever your deployment already does with `logging`. ```python theme={null} import logging logging.getLogger("standin").setLevel(logging.DEBUG) ``` The TypeScript SDK has no stdlib logging tree to hang this on, so it exports a `setLogger` function instead. That difference is the only one between the two SDKs here. Every call id that reaches a log line is rendered log-safe first: control characters are replaced and the length is bounded, so an id chosen by somebody else cannot forge a log line of its own. ## Limits * One inbound message is bounded at **2 MB**. Audio is about 856 bytes of base64 per frame, and the protocol caps a `video.frame` JPEG to fit inside the same envelope. * Outbound audio is bounded by the 1 MB shed threshold above. * An outcome POST body is bounded at 8 KB. * A frame type this SDK does not recognise is ignored rather than refused. ## start and aclose `await server.start()` binds the listener and is transactional: either it is listening when the call returns, or nothing of it survives. A failed bind cleans up its own runner before re-raising. Starting twice raises `StandInError` rather than quietly binding a second listener. `await server.aclose()` stops the reaper, **ends** every live call with the reason `server-shutdown`, and then stops listening. It does not wait for a call to finish on its own, which is why a graceful restart is `draining` first and `aclose()` after. It awaits the calls' real teardown tasks, so the loop cannot stop with teardown still pending. ```python theme={null} import asyncio from standin import CallServer async def serve() -> None: server = CallServer(handler_factory=MyHandler) await server.start() try: await asyncio.Event().wait() # run until cancelled finally: await server.aclose() ``` ## Draining `server.draining = True` refuses new calls. Live calls continue. New dials are refused with `503 draining`, so a worker that is winding down does not accept calls it will never serve. Set it as the first step of your shutdown, wait for `server.active_calls` to reach zero, then `aclose()`. ```python theme={null} async def shutdown(server: CallServer) -> None: server.draining = True # new dials get 503; live calls continue while server.active_calls > 0: await asyncio.sleep(1) await server.aclose() ``` The LiveKit plugin wires this up for you: it sets `draining` when the LiveKit worker itself starts draining, and closes the listener when the worker shuts down. ## What the upgrade handler refuses, in order The order matters, because each check is cheaper than the one after it. | Response | Condition | | ------------------------------------- | ------------------------------------------------------------------------ | | `400 missing callId` | The dialed path had no `callId` segment. | | `503 draining` | The worker is winding down. | | `503 at capacity` | `max_connections` live calls already. Checked before any signature work. | | `401 unauthorized` | Missing, malformed, stale or wrong signature. | | `401 handshake already used` | A valid signature replayed inside the freshness window. | | `409 call already has a live session` | That `callId` already has an open socket. | See [Security](/python-sdk/security) for how the signature and the replay guard work. ## Teardown Teardown runs once per call, in its own task, and it is shielded: a caller being cancelled must never abort it mid-flight, because that is how a slot leaks and a `callId` starts refusing forever. The order is fixed. 1. Cancel the call's own background tasks and wait for them. 2. If `on_start` is still on the stack, wait briefly for it to unwind. Dispatching `aclose` underneath a running `on_start` would tear down half-built state, and `on_start` would then resume and finish building a provider session nothing will ever close. 3. Dispatch the handler's `aclose(reason)`, before the socket closes, so a handler that wants to speak on the way out still can. 4. Write the advisory `session.end`, then close the socket with a two-second bound. Releasing the slot matters more than a clean close handshake with a peer that has already gone away. 5. Release the slot, unconditionally, whatever failed above. The first close reason wins. A cascade of close causes must not overwrite the one that actually ended the call. [Call handler](/python-sdk/call-handler#aclose) lists the reasons a handler will see. ## Next The five methods this server drives, and the session it hands them. The handshake, the replay window, and the two signing lanes. # Call tools Source: https://docs.komaa.com/python-sdk/call-tools One set of call capabilities, declared in every provider's own JSON, in the StandIn Python SDK. A voice model can talk. What it cannot do, unless you tell it, is hang up, put a picture on its own video tile, react with an expression, or look at what the caller is showing. Those are properties of being on a Microsoft Teams call, not of any provider, so they live in the SDK. Every plugin gets the same set, and a caller gets the same agent whichever provider answers. ## The capabilities | Tool | Parameters | What it does | | ------------ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `end_call` | none | Hang up. | | `express` | `emotion` | Show an emotion on the avatar's face. | | `show_image` | `url`, `caption?`, `display?` | Put a picture on the bot's video tile. `display` is an enum, `fullscreen` or `overlay`, because a model obeys an enum in the schema far more reliably than the same list written into a description. | | `look` | `source?`, `question?` | Look at the caller's camera or screen share. | | `look_back` | `question?` | Look again at something already moved past. Recorded calls only. | The descriptions are written **for a model**. They say when to reach for a tool rather than what the code does, because that sentence is the only thing the model reads before deciding. `SHOW_PAGE_TOOL` is the sixth capability and is deliberately **not** built in. The built-in list is declared unconditionally, so a deployment with no renderer would be telling every model it can show a web page, promising the caller and then apologising on every call. An absent tool is honest; a broken one is not. A plugin that actually has a renderer registers it: ```python theme={null} from standin import SHOW_PAGE_TOOL tools.register(SHOW_PAGE_TOOL, show_page) ``` See [Showing a web page](/python-sdk/vision#showing-a-web-page). ## Declaring them Every provider invented its own JSON for "here is a function you may call", so `schemas` renders one list in the shape yours wants. ```python theme={null} from standin import CallTools, VisionTools tools = CallTools(session, vision=VisionTools(session, describer=describer)) agent.declare(tools.schemas("flat")) ``` | Dialect | Shape | Who takes it | | ----------- | ----------------------------------- | --------------------------------------------- | | `flat` | `{name, description, parameters}` | Deepgram's Settings, ElevenLabs' client tools | | `openai` | the same, tagged `type: "function"` | the Realtime API | | `anthropic` | `{name, description, input_schema}` | the Messages API | An unknown dialect falls back to `flat` rather than raising. A tool a model never sees is a worse outcome than a shape one provider happens to also accept, and a crash at connect time helps nobody. `tool_schemas(dialect, extra)` is the same rendering without a session, for a plugin that wants the declarations before there is a call to bind them to. `CallTools.schemas` is a thin wrapper over it. ```python theme={null} from standin import BUILT_IN_TOOLS, tool_schemas declarations = tool_schemas("openai", extra=(my_spec,)) ``` `BUILT_IN_TOOLS` is the tuple your own specs are checked against, which is why a tool of your own may not shadow one and why that refusal happens at registration. ## Running them Translate your provider's tool-call frame into a name and a dict, and hand it over: ```python theme={null} result = await tools.dispatch(name, params) # never raises ``` **Dispatch never raises.** It returns a sentence, because the result goes back to a model that will read it out. "I could not show that because the image was too large" is worth something to a model; a traceback is not. When your provider's tool-result frame also carries an error flag, use `run` instead: ```python theme={null} outcome = await tools.run(name, params) # a ToolResult: .text and .ok agent.send_result(call_id, outcome.text, is_error=not outcome.ok) ``` `ToolResult` is `from standin.calltools import ToolResult`, not the `standin` barrel, because a caller reads its two attributes far more often than it names the type. `ok` is false only when the SDK **knows** the tool did not do what it was asked: a missing or malformed argument, a rejected value, a handler that raised, or a name no tool answers. It is not a verdict on the vision tools, which answer in sentences by design, so "there is nothing to look at" comes back as ok with the reason in the text. ## Tools of your own Your own tools go in the same place, so the model sees one list: ```python theme={null} from standin import ToolSpec tools.register( ToolSpec( "open_ticket", "Use this when the caller reports a fault that needs following up.", {"summary": {"type": "string", "description": "What is wrong, in one line."}}, required=("summary",), ), open_ticket, ) ``` The handler may be sync or async and returns what the model is told. Keep it fast: the caller is listening to silence while it runs. A tool of your own may not shadow a built-in. `register` raises `ValueError` when you try, at registration rather than at the first call, because a shadowed `end_call` is an agent that has quietly lost the ability to hang up, and that is not something to discover mid-conversation. The provider plugins already do all of this. Reach for `CallTools` when you are writing a plugin of your own, or adding capabilities to a handler you wrote yourself. ## Tools that are not built in Five more capabilities ship as `ToolSpec` values you register yourself, for the same reason as `SHOW_PAGE_TOOL`: each one depends on something the call may not have, and an agent told it can do something it cannot is worse than one that never mentions it. | Spec | Register it when | Page | | ---------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------- | | `CONSULT_TOOL` | A second, slower agent exists to delegate to. | [Consulting](/python-sdk/consulting) | | `BACKGROUND_TASK_TOOL` | There is somewhere to deliver a result after the call. | [Consulting](/python-sdk/consulting) | | `MINUTES_TOOL` | The call resolves to a conversation the recap may be posted into. | [Meeting recap](/python-sdk/minutes) | | `CALL_BACK_TOOL` | An outbound lane and its allowlist exist, so the agent can ring this caller again later. | [Reaching people](/python-sdk/reaching-people) | | `CHAT_CALLBACK_TOOL` | The same lane, for ringing the person you are chatting with instead of replying in writing. | [Reaching people](/python-sdk/reaching-people) | All five are on the `standin` barrel, and all five go through `tools.register(spec, handler)` exactly as your own do. ## Whether to answer at all A tool decides what an agent can do. In a meeting, the prior question is whether it should say anything, and the SDK ships that separately: `GroupGate` and the wake-phrase matching around it. It is inert on a one-to-one call, so a plugin that never configures a phrase behaves exactly as it did before. See [Group calls](/python-sdk/group-calls). # Chat Source: https://docs.komaa.com/python-sdk/chat Answer Microsoft Teams messages with ChatChannel in the StandIn Python SDK: dialed out, no listener, no bot credential. `ChatChannel` is the messages lane. It is the same shape as the call lane, and it is deliberately the easier half: the worker dials **out** and StandIn pushes messages down that socket. That means no listener, no open port and no tunnel for chat. It also means your agent never holds a Bot Framework credential: StandIn owns the Microsoft Teams bot, authenticates the activity, resolves it to your connection, strips the bot mention, and performs the send on your behalf. Your handler returns text. ```mermaid theme={null} flowchart TD T["Microsoft Teams message"] --> G["StandIn
authenticates, normalizes, signs"] G -->|"pushed down the worker's outbound socket"| C["ChatChannel"] C -->|"your async handler returns reply text"| G G --> T2["StandIn sends it to Microsoft Teams"] ``` Only on the StandIn AI Teammate app, and that needs no flag: the socket authenticates with your connection secret, so if it opens at all you are on the StandIn AI Teammate app. A meeting recap that only posts uses `listen_only=True` so it never answers. ## Meeting recap `listen_only=True` is how a recap lane posts without answering. The host must supply that outbound chat lane (`ChatChannel.start()`) and a summarization callback (`post_minutes` or the plugin consult). Hang-up does not await the post. Meeting recaps are best-effort. They may be lost if the worker exits during processing. ## Minimal use ```python theme={null} import asyncio from standin import ChatChannel, InboundMessage async def on_message(msg: InboundMessage) -> str: return f"You said: {msg.text}" async def main() -> None: chat = ChatChannel(respond=on_message) await chat.start() try: await asyncio.Event().wait() finally: await chat.aclose() asyncio.run(main()) ``` `respond` is an async callable taking an `InboundMessage` and returning the reply text. Returning an empty string makes the channel say so rather than leaving the user watching a typing indicator forever. ## Constructor ```python theme={null} ChatChannel(*, respond, secret=None, url=None, chats=None, listen_only=False) ``` | Argument | Default | Meaning | | ------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `respond` | *(required)* | `async (InboundMessage) -> str`. | | `secret` | `STANDIN_CHAT_SECRET`, else `STANDIN_SECRET` | The key this lane signs with. No secret at all raises `StandInError` naming both variables. | | `url` | `STANDIN_CHAT_URL`, else `wss://teams.standin.komaa.com/api/chat/channel` | The chat channel to dial. | | `chats` | `None` | A [`PersonalChats`](#remembering-who-messaged-you) to feed. Every personal message that arrives is remembered in it, which is what later lets a call post back to the caller who sent one. | | `listen_only` | `False` | Take messages without answering them. `chats` is still fed, `respond` is never called, and nothing is sent back, not even the typing indicator. For a lane that only posts, such as a meeting recap, when something else already answers this connection's chat. | `await chat.start()` dials StandIn and begins taking messages. `await chat.aclose()` cancels the in-flight turns and closes the socket. ### Which secret A StandIn AI Teammate app connection may issue a **separate key for chat**. When it does, set `STANDIN_CHAT_SECRET` and it wins; with one key for both lanes, set only `STANDIN_SECRET` and the chat lane falls back to it. A key scoped to one lane cannot be used on the other, which is the point of issuing two. In Python a blank `STANDIN_CHAT_SECRET` falls through to `STANDIN_SECRET`; the TypeScript twin stops at the first variable that is merely present. Unset the variable rather than blanking it and the two behave alike. See [Configuration](/python-sdk/configuration). ## InboundMessage One user message, already authenticated and resolved to your connection. Reserved bot commands are handled by StandIn and never arrive here. In group and channel scope, only messages that mention the bot are relayed, and the mention is already stripped from `text`. | Field | Type | Notes | | ------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tenant_id` | `str` | The Microsoft tenant. Echo it back exactly. | | `conversation_id` | `str` | The Microsoft Teams conversation. Echo it back exactly. | | `activity_id` | `str` | The activity this is a reply to, and the idempotency key. | | `scope` | `str` | `personal`, or a group or channel value. An open enum: an unknown value relays as a group chat rather than being rejected. | | `text` | `str` | The message, with any bot mention removed. Empty on a card action. | | `sender_name` | `str \| None` | Display name. | | `sender_aad_id` | `str \| None` | AAD object id, when Microsoft Teams reported one. | | `sender_is_guest` | `bool` | | | `sender_is_linked_owner` | `bool` | The sender is the owner this connection is linked to. | | `attachments` | `list[dict]` | A pasted screenshot, a dragged-in file or a voice note, as raw dictionaries. `build_chat_turn` turns them into something answerable: see [Attachments in chat](/python-sdk/chat-attachments). | | `mentions` | `list[str]` | | | `locale` | `str \| None` | | | `card_action` | `dict \| None` | The submit payload of an `Action.Submit` on a card this agent sent. `text` is empty on these. | | `binding_id` | `str \| None` | Which StandIn connection this conversation resolved to. One tenant can hold several connections, so the tenant alone no longer says who a reply is from, and `build_reply` echoes it. | | `is_personal` | `bool` | Property. True when `scope == "personal"`. | ```python theme={null} async def on_message(msg: InboundMessage) -> str: if not msg.is_personal: # In a group chat you were mentioned by name; keep it short. return await agent.brief_answer(msg.text) return await agent.answer(msg.text) ``` ## parse\_inbound ```python theme={null} from standin import parse_inbound message = parse_inbound(body) # raises ValueError naming the problem ``` Parses and validates one inbound message body. It raises `ValueError` with the specific problem, which a caller maps to HTTP 400: `malformed json`, `body must be an object`, `tenantId is required` and the same for `conversationId` and `activityId`, each of which has to be a non-empty string, or an unsupported `schemaVersion`. Everything else has a safe default, including `scope`, which reads as `personal` when the message does not say. `ChatChannel` calls this for you and logs and drops anything malformed. Use it directly only when you are receiving the relay yourself, over the POST lane rather than the socket. The exception type differs between the SDKs: this one raises `ValueError`, the TypeScript twin throws `StandInError`. A caller mapping the failure to HTTP 400 catches a different class in each. ## build\_reply ```python theme={null} from standin import build_reply reply = build_reply(message, "Here is what I found.") typing = build_reply(message, "", "typing") failed = build_reply(message, "Something went wrong.", "error") with_picture = build_reply(message, "Here is the chart.", image=picture) ``` ```python theme={null} build_reply(message, text, kind="message", image=None) ``` Builds the reply StandIn expects: `schemaVersion`, `tenantId`, `conversationId`, `replyToId`, `kind`, and an `idempotencyKey` of `"{activityId}:{kind}"`. It adds `bindingId` when the inbound carried one. `text` is omitted for `kind="typing"`, and so is `image`: a typing indicator is a state, not a message. `image` is an `OutboundImage` from `outbound_image`. See [Sending a picture](/python-sdk/sending-pictures). `tenantId` and `conversationId` echo the inbound message **exactly**. StandIn rejects a mismatch, and that check is the cross-tenant leak guard the whole relay rests on. Never construct a reply with a tenant or conversation you assembled from somewhere else, and never reuse an `InboundMessage` from one conversation to answer another. `bindingId` echoes for the same reason one level down, between connections inside one tenant. That is also the reason `build_reply` takes the message rather than two strings. Passing the message through is what makes the echo automatic. ## SCHEMA\_VERSION ```python theme={null} from standin import SCHEMA_VERSION # 1 ``` A major version. Additive evolution does not bump it, because the schema already requires receivers to ignore unknown fields. An integer above yours therefore means incompatible semantics, and `parse_inbound` refuses the message rather than guessing. ## What the channel does for you Beyond parsing, `ChatChannel` handles four things that are easy to get wrong. **Deduplication.** StandIn delivers at least once. A redelivery of the same `tenantId:conversationId:activityId` must not start a second turn, so the channel keeps a bounded LRU of what it has seen. An aged-out redelivery running again is acceptable at-least-once behaviour; a fresh double-run is not. **Per-conversation ordering.** The schema promises ordering within a conversation, so turns for one conversation are chained rather than run as independent tasks, which would let replies overtake each other. Different conversations still run concurrently. A failed turn does not dam the chain behind it. **Typing indicators.** A typing reply is sent before your handler runs, and it does not sit in front of the turn. The indicator still lands first. **Bounded turns.** Because turns within a conversation are serialized, a hung turn would wedge that conversation forever. Each one is bounded at `ChatChannel.TURN_TIMEOUT_S`, which is 300 seconds: generous, because agent turns legitimately run long. A timeout or an exception sends an error reply rather than silence, since after a typing indicator, silence looks exactly like a hang. It is a public class attribute, so you can raise or lower it; the TypeScript twin holds the same bound in a module constant a caller cannot change. ## Remembering who messaged you A one-to-one **call** carries no thread id that can be posted into. So the only honest source for "where do I send this person something" is a message that person actually sent, and `PersonalChats` is where those are remembered. ```python theme={null} from standin import ChatChannel, PersonalChats chats = PersonalChats() chat = ChatChannel(respond=on_message, chats=chats) # later, on a call target = chats.for_caller( caller_aad_id=session.start.caller.aad_id, tenant_id=session.start.tenant_id, ) ``` `remember()` is fed by the channel on every inbound message, and `for_caller()` is asked from the call lane. At most 512 senders are kept, keyed by tenant and directory id, oldest evicted, because a tenant with many users must not grow without bound inside a worker that is also carrying live audio. A record stays usable for `CHAT_FALLBACK_WINDOW_MS`, which is 12 hours: somebody who messaged this morning and calls this afternoon is plainly the same person in the same working context, and a record older than that is a guess about who is on the phone. `PersonalChat` is the record itself: `conversation_id`, `tenant_id`, `aad_id`, `display_name` and `at_ms`. Three decisions inside it are worth knowing, because each one closes a way of posting call content into the wrong conversation: **Scope decides what is personal, and only scope.** A conversation-id prefix looks like it would do the same job and does the opposite: a personal chat with a bot is addressed `a:1...` while `19:...` is exactly the group and channel shape this has to exclude. Without the scope test, an @mention in a team channel would make that channel somebody's "personal" chat and put their private escalation in front of their team. **A message is remembered behind the dedupe.** A redelivery of this morning's message must not make the sender look like they messaged just now, because the recency window is evidence about who is on the phone and a repeat is not. That last one is the one real behavioural difference between the SDKs on this lane: the TypeScript twin remembers a message **before** the dedupe runs, so a redelivery there refreshes the 12 hour window. The dedupe still governs the turn in both, so only the freshness of the record differs. **`for_caller` enforces four rules, all of which must hold.** The conversation was recorded from a personal-scope message; it belongs to the tenant this worker is bound to, never the caller's own, which is absent or foreign for a guest; it was seen inside the window; and the call names its caller, whose directory id is the remembered sender's. A call that identifies nobody gets nothing. `allow_unidentified=True` relaxes the last rule for a single-operator install, and warns by name every time, because with it on every anonymous caller collapses onto whoever chatted last. Both lanes have to run in **one process**. `PersonalChats` is in-memory: across processes a caller who has not messaged the bot inside the window simply has no personal target. This is where [Meeting recap](/python-sdk/minutes#where-the-minutes-go) gets the `caller_chat` it resolves a delivery target from. ## Posting without an inbound message ```python theme={null} await chat.send( tenant_id=session.start.tenant_id, conversation_id=session.start.thread_id, text="Here are the notes from that call.", idempotency_key=f"{session.call_id}:notes", ) ``` ```python theme={null} chat.send(*, tenant_id, conversation_id, text, image=None, binding_id=None, idempotency_key=None) ``` Useful from inside a call. Best-effort: it returns `False` rather than raising, because a failed post must never break a live call. Pass an `idempotency_key` when a retry would otherwise post twice. `image` attaches one picture, the same `OutboundImage` `build_reply` takes. `binding_id` names which of a tenant's connections the post is from, and there is no inbound message here to echo it off, so pass it yourself when the tenant has more than one. ## Authentication The outbound dial signs the channel name with `sign_handshake`, using your connection secret, in the `X-StandIn-Timestamp` and `X-StandIn-Signature` headers. That is the same v1 scheme the inbound call handshake uses, in the opposite direction: here your worker signs and StandIn verifies. The separate POST relay lane signs the **body** instead, with a longer window. The two are not interchangeable. See [Security](/python-sdk/security). ## Next One call turns a pasted screenshot, a file or a voice note into a turn your agent can answer. `outbound_image`, the checks it runs, and why a reply carries bytes rather than a link. Both signing lanes, and why they differ. The voice half of the same connection. # Attachments in chat Source: https://docs.komaa.com/python-sdk/chat-attachments Turning a pasted screenshot, a dragged-in file or a voice note into one ChatTurn your agent can answer, in the StandIn Python SDK. A Microsoft Teams message can carry a pasted screenshot, a file dragged in from disk, or a voice note. Without any of this, your handler gets `InboundMessage.attachments` as raw dictionaries, so the best it can do is read a JSON blob to a model and the worst is answer a message about a picture as though nothing had been sent. ## One call does the whole message ```python theme={null} from standin import build_chat_turn turn = await build_chat_turn(message, transcribe=my_stt) answer = await agent.ask(turn.query, images=[image.data_url for image in turn.images]) ``` `build_chat_turn` takes the `InboundMessage` your [chat handler](/python-sdk/chat) was given and returns a `ChatTurn`: the text to ask, the pictures to hand over, and a plain sentence naming anything that came with the message. | Field | Type | What it is | | ----------------- | ----------------- | ------------------------------------------------------------------ | | `query` | `str` | The text to put in front of the model, including every note below. | | `images` | `list[ChatImage]` | Pictures that were fetched and are ready for a vision model. | | `voice_note` | `str` | What the voice notes said, or empty. | | `attachment_note` | `str` | The "what was attached" sentence, on its own. | ```python theme={null} turn = await build_chat_turn( message, origin=None, # None means derive it; see the origin pin below images=True, # False to skip image fetching entirely transcribe=None, # your speech-to-text callable, or no transcription ) ``` `images=False` and no `transcribe` make it a pure text assembler that opens no sockets at all. A handler that fetches nothing pays nothing. There is a fourth keyword, `get`, which substitutes the opener every request goes through: it exists so a test can reach every edge of this page without a socket. This lives in `standin.attachments`, deliberately **not** in `standin.chat`. That module owns the socket, the duplicate check and the per-conversation ordering, and none of that changes here. Fetching is optional work that must never be able to wedge the transport, so it sits beside it rather than inside it. Every function and class on this page is re-exported at the package root, so `from standin import ...` reaches it. The caps are not, and each one below spells the module import it needs. ## The order is the order a person would say it in `query` is assembled in a fixed order, and the order is not arbitrary: it is the order somebody would have said the same thing out loud. 1. **What they typed.** `message.text`, stripped. 2. **What they pressed.** The submit payload of an `Action.Submit` on a card this agent sent, as `card_action_note`. A card message arrives with **empty text**, so without this the agent is asked nothing at all and answers as though the person said nothing. The payload is serialized and truncated at 4096 characters, which bounds this agent's own card template rather than a stranger's message. 3. **What they said out loud.** The transcribed voice notes, under `[They sent a voice message]`. 4. **What they attached.** The note from `attachments_note`, under `[Attached to this message]`. Each part is omitted when it is empty, and the parts are joined by blank lines. A message with nothing attached produces a `query` that is exactly the text the person typed. ## It never raises `build_chat_turn` does not raise. Anything that will not load is **named in the note** instead of failing the turn, because a failed picture is not a reason to leave a question unanswered. The note distinguishes the two outcomes per attachment: ```text theme={null} [Attached to this message] - plan.png [image] (attached) - q3.xlsx [file] (unreadable) ``` That distinction is the point of the note. A model told a picture was attached and could not be opened says something useful. A model told nothing answers as if the message were empty. And a model told a picture was attached, when nothing was actually fetched, answers **about a picture it has never seen**, which is the worst of the three. The note is bounded at ten entries followed by a count of the rest, so a message with forty attachments does not become the whole prompt. `attachments_note(attachments, status=...)` is callable on its own, where `status` maps an attachment's index in the message to the word in its brackets. Neither that ten nor the 4096 above is part of the module's exported surface, where the TypeScript twin names both at its root as `ATTACHMENT_NOTE_MAX_LINES` and `CARD_PAYLOAD_MAX_CHARS`. Read them here rather than importing them. ## Images ```python theme={null} from standin import ChatImage ``` | Field | Type | What it is | | ------------- | ------------- | --------------------------------------------------------------- | | `data_base64` | `str` | The image as it arrived, base64. | | `mime` | `str` | The real media type. The response's own header wins; see below. | | `name` | `str \| None` | The filename, when there was one. | | `size_bytes` | `int` | How big it was. | | `data` | `bytes` | Property. The decoded bytes, for an API that uploads a file. | | `data_url` | `str` | Property. The `data:` form most vision APIs take directly. | `data` and `data_url` are **properties** here. `ChatImage` is an interface in the TypeScript SDK, and an interface carries no accessors, so the same two conveniences are the free functions `chatImageData` and `chatImageDataUrl` there. A search for `.dataUrl` across that SDK finds nothing. `fetch_chat_images(attachments, origin=...)` is the piece underneath, for a handler that wants the pictures without the rest of the turn. It is best-effort per attachment: one that will not load costs that attachment, never the answer. Every cap below is also a keyword argument on it, as `max_bytes`, `max_images` and `timeout_s`, so a handler that wants a tighter number passes one rather than reaching for the constant. The caps are not at the package root. Spell the module: ```python theme={null} from standin.attachments import ( IMAGE_FETCH_ATTEMPTS, # 8 IMAGE_FETCH_TIMEOUT_S, # 10.0 MAX_IMAGES, # 4 MAX_IMAGE_BYTES, # 4 MiB ) ``` `MAX_IMAGES` is the **accept** cap: four pictures are kept from one message, because each becomes a base64 blob in front of a model. `MAX_IMAGE_BYTES` is what one of them may weigh, and it matches the per-attachment ceiling StandIn applies anyway, so a larger local number could never be reached. `IMAGE_FETCH_TIMEOUT_S` is how long one image has to arrive, **in seconds**. The TypeScript twin counts the same budget in milliseconds under a different name, `IMAGE_FETCH_TIMEOUT_MS`, so a number carried across unchanged is off by a factor of a thousand. `IMAGE_FETCH_ATTEMPTS` is a **separate** cap, and it is the one that keeps the worst case arithmetic. The accept cap counts only successes, so without it a message naming fifty attachments that all time out still costs fifty timeouts and blows the whole turn budget while nothing is ever accepted. The attempt cap counts requests whatever the outcome: eight tries at ten seconds, and that is the ceiling. Two more things the fetch does, both about what arrives rather than what was claimed. The media type is judged **before a byte is read**, and the response's own header wins, because an error page would otherwise be base64'd in front of a model as though it were a picture. What the message declared is the fallback, and only when it is itself a media type: a dragged-in file declares a bare extension, and taking that literally would fail every `audio/` check and spool the bytes under a nonsense name. And the size cap holds **while** the body is read rather than after it, because a `content-length` that lies, or is simply absent, otherwise gets to allocate whatever it likes before a later check objects. This `MAX_IMAGE_BYTES` is not the only one in the SDK, and the two are different numbers for different things. The one here, in `standin.attachments`, is how big an **inbound** attachment may be before this worker refuses to read it. There is a second `MAX_IMAGE_BYTES` in `standin.vision`, and that one is the **outbound** cap on an image you put on the bot's video tile: a smaller number, on a different direction of travel. Neither is re-exported at the package root, so the module you import from is what says which you meant. The two SDKs differ here, and in the direction that matters for porting. The TypeScript SDK **does** export `MAX_IMAGE_BYTES` from its root, and the one it exports is the outbound number: passing it where an inbound cap belongs compiles cleanly and quietly caps attachments at the smaller value. Python has no such collision to fall into, because neither name is at the root to be picked up by accident. See [Vision](/python-sdk/vision#fetching-an-image-a-model-chose). ## A dragged-in picture is a file, not an image A pasted screenshot arrives with `kind` of `image`. The **same file** dragged in from disk arrives with `kind` of `file` and a `contentType` that is a bare extension like `png` rather than a media type. So gating on the declared kind alone is the trap here, and it is a quiet one: the picture is never fetched, but the note still says one was attached, and the model answers about a picture nobody gave it. An attachment is therefore worth a request when its kind matches **or** when its kind is exactly `file` and its filename's extension, or a declared type that is itself a bare extension, is one of `png`, `jpg`, `jpeg`, `gif`, `webp`, `bmp`, `heic`, `heif`. The widening is `file` only: it exists to rescue the dragged-in case, not to override a kind the message stated outright. What the file then turns out to be is decided by the response, not by the extension that got it tried. An attachment StandIn marked as not relayable is skipped without a request. ## Voice notes ```python theme={null} from standin import ChatAudio, Transcriber ``` `ChatAudio` is one clip before anything has transcribed it: `data` as bytes, `mime`, and `name`. `fetch_chat_audio` returns them. `video/` types are accepted as well as `audio/`, because some clients label a voice note with a container type that speech-to-text reads perfectly well, and the same extension widening applies: a voice note relays as a file in practice, so a plugin gating on `kind == "audio"` transcribes nothing, ever. `Transcriber` is the callable **you** supply: ```python theme={null} Transcriber = Callable[[bytes, str], Awaitable[str]] # (data, mime) -> text ``` The core ships none and reads no provider key. Deciding which speech vendor somebody's voice is sent to is not a decision an SDK gets to make on their behalf, and a default would make it silently. ```python theme={null} from standin import transcribe_voice_messages said = await transcribe_voice_messages(attachments, transcribe=my_stt, origin=origin) ``` It returns the clips as one block of text, and empty when there are none, when no transcriber was supplied, or when every one failed. With no transcriber it fetches nothing at all rather than downloading audio that has nowhere to go. A transcriber that raises costs that clip and nothing else. The clip budgets are deliberately larger than the image ones: ```python theme={null} from standin.attachments import ( CLIP_FETCH_ATTEMPTS, # 4 CLIP_FETCH_TIMEOUT_S, # 20.0 MAX_CLIPS, # 2 MAX_CLIP_BYTES, # 16 MiB ) ``` A voice note is minutes of audio where a picture is one screen, so it needs four times the bytes and twice the wall clock to arrive. The attempt cap is halved to pay for that: four tries at twenty seconds is the same eighty second worst case as eight tries at ten. And two clips rather than four, because each accepted clip is a transcription call on top of the download. ## An engine that only takes a path Plenty of transcription engines will not take bytes. `spool_clip` is a context manager that writes the clip to a temporary file, named with the extension its media type implies, and hands you the path: ```python theme={null} from standin import ChatAudio, spool_clip async def my_stt(data: bytes, mime: str) -> str: with spool_clip(ChatAudio(data=data, mime=mime)) as path: return await engine.transcribe_file(path) ``` The file is removed **on the way out, on every path**, including the one where the engine raised. That is not tidiness. A transcription engine is being handed somebody's voice, and a clip left behind in a temporary directory is a copy nobody decided to keep, sitting on disk long after the conversation it came from ended. Pass `directory` to spool somewhere other than the system temporary directory, when an engine needs the file on a particular volume. Both differences from the TypeScript twin are here. This one is a context manager, which is the shape Python has for "hold this open for exactly this long", while that one takes a callback. And this one takes `directory`: there is no such argument there, so that SDK always spools into the system temporary directory. ## The origin pin Every fetch on this page is pinned to the one origin the messages themselves arrived from: ```python theme={null} from standin import attachment_origin attachment_origin() # from STANDIN_CHAT_URL, else the default attachment_origin("wss://example.com/api/chat/channel") # "https://example.com" attachment_origin("ws://127.0.0.1:9444/x") # "http://127.0.0.1:9444" ``` It is derived from the chat channel's own URL rather than configured separately, so it is right by construction for a self-hosted or local setup and there is no second setting to get wrong. `ws` maps to `http` and `wss` to `https`, and a default port is not spelled out, so `wss://host:443` and `wss://host` compare equal. The name is `attachment_origin` here and `chatAttachmentOrigin` in the TypeScript SDK. That is the one name on this page that is not a straight casing translation of its twin, so a search for `attachment_origin` across that SDK finds nothing. It **fails closed**. A URL it cannot resolve, and anything that is not `http`, `https`, `ws` or `wss`, returns `None`, and `None` means fetch nothing at all. That direction is the whole design. An attachment URL is signed, but not by you and not for you: it arrives inside a message somebody else wrote, and the pin is the only thing bounding where this worker can be told to go. An unset origin read as "anywhere" would turn a configuration typo into a fetcher that a stranger's message can point at any address it likes, an internal service or a cloud metadata endpoint included. Two consequences worth knowing. An attachment whose URL is not on that origin is refused **without a request**, so the log line is the only cost. And no redirect is followed: the URL is same-origin and signed, so a redirect off it is already anomalous, and following one would reopen the door the pin just closed, since the pin is checked on the URL in the message and not on wherever a `302` points. Pass `origin=` explicitly when your handler already knows it. Otherwise `build_chat_turn` derives it for you. ## Next The channel these messages arrive on, and what goes back out. The two signing lanes, and what the SDK refuses. # Checking the install Source: https://docs.komaa.com/python-sdk/checking-the-install Ring this worker's own handler on loopback and prove audio made the round trip, without a provider bill, a public tunnel or a Microsoft tenant, in the StandIn Python SDK. Every plugin has to answer one question before anybody trusts it with a real call: does this actually work here? Until now the only way to find out was to place a real call, which costs a provider bill, a public tunnel, a Microsoft Teams tenant and somebody's afternoon. `run_smoke` answers it on loopback in under a second. It binds an ephemeral listener, connects a real client that speaks the real call wire, streams a few frames of silence, and reports what came back. ```python theme={null} import asyncio from standin.smoke import report, run_smoke from my_plugin import MyHandler async def main() -> int: result = await run_smoke(MyHandler) print(report(result)) return 0 if result.ok else 1 raise SystemExit(asyncio.run(main())) ``` `handler_factory` is the same callable `CallServer` takes, called once. A handler class with a no-argument constructor works as it is; anything else goes in a `lambda`. The printer is on the package as **`smoke_report`**, not `report`. The module names the function `report` and the package renames it on the way out, so `from standin import report` does not resolve. `SyntheticCall`, `run_smoke`, `SmokeCheck` and `SmokeResult` keep their own names. ## What it prints ```text theme={null} standin: ok ok secret (generated for this run) ok listener (127.0.0.1:55064/msteams/calling) ok call (10 frames in 235 ms) ok audio (10 frames came back) ``` And when the handler never sent anything back: ```text theme={null} standin: NOT ok ok secret (generated for this run) ok listener (127.0.0.1:55073/msteams/calling) ok call (4 frames in 93 ms) FAIL audio (0 frames came back) without it: the caller would hear nothing ``` Every failed check prints what stops working without it. A report that names a missing surface and not the consequence sends the operator to the source to find out whether it matters. `report` returns the string rather than printing it, so a plugin can put it in a log line, a tool result or an HTTP response. When the run recorded an `error`, a final ` error: ...` line follows the checks. ## The four checks They run in this order, and each one is the next thing that breaks in the field. | Check | What it proves | What the failure costs | | ---------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `secret` | A credential exists for this run. | nothing could authenticate | | `listener` | The worker bound its socket and has a call path. The detail is the bound address. | no call could ever reach this worker | | `call` | Connect, handshake, `session.start`, the frame loop and hang-up all completed. | a real call would fail the same way, or would hang the same way if the run timed out | | `audio` | Frames came back out of the handler. | the caller would hear nothing | All four are mandatory, and `result.ok` is true only when every mandatory check passed. **`ok` is never true when nothing echoed.** The whole point of the run is that audio made the round trip, so a run that proved nothing must not read as a pass. A listener that binds, authenticates and then says nothing is exactly the install people ship by accident. The run sends `recording.status: active` before the first frame. Handlers commonly gate their output on the call being recorded, and without it a recording-gated handler stays silent and the run reports a false negative. Frames are PCM16 mono at 16 kHz, 20 ms each, which is `FRAME_BYTES` (640) at the cadence a real call arrives at. `frames` defaults to 10. If the listener cannot bind, the run returns there and then with two checks, a failed `listener` carrying the bind error, and `echo_frames` at 0. There is nothing left to ring. The two results are plain dataclasses: ```python theme={null} @dataclass(frozen=True) class SmokeCheck: name: str ok: bool detail: str = "" cost: str = "" required: bool = True @dataclass class SmokeResult: checks: list[SmokeCheck] = field(default_factory=list) echo_frames: int = 0 error: str = "" @property def ok(self) -> bool: ... ``` ## It never borrows your configuration The run generates its own connection secret, `secrets.token_hex(16)`, for that run only. It never reads `STANDIN_SECRET`. Two reasons, and both are real failures rather than tidiness. A fixed string would be a predictable credential on a live listener. The check is meant to be safe to run anywhere, including on a host that is answering calls. Borrowing the operator's secret makes the run pass or fail for reasons that have nothing to do with the wire, and it makes the check impossible to run before the secret is configured. That is exactly when people run it. ## Loopback, on port 0 The listener binds `127.0.0.1` on port `0`, and the port it actually got is read back after it is listening. Loopback because a verification run has no business being reachable from the network. Port `0` because the alternative is picking a free port in advance with a throwaway socket, which races whoever binds it next. Two runs in the same CI job, or a run next to a real listener, and the loser fails for a reason that has nothing to do with the install. ## Nothing that delivers is started **This is the most important property on the page.** No pending-message sweep, no no-answer reaper, no outbound caller, no chat lane. Nothing that delivers is started, and the listener's own stale-call reaper is off for the run (`stale_call_reaper_seconds=0`). A verification command that quietly resumed durable jobs would place real calls and post real messages as a side effect of somebody checking an install. Durable work is durable on purpose. Parked messages, background-task promises and unanswered outbound legs all survive a restart, because the person they are for is still owed the answer. That is correct for a worker coming up, and completely wrong for a check somebody typed to find out whether an install works. `run_smoke` starts a listener and a handler, and nothing else. The listener is closed with `aclose()` in a `finally`, on every path including the failing ones, so a run leaves nothing bound behind it. ## A plugin's own checks `extra` runs after the call and returns more checks, so a plugin can prove its own surfaces in the same report. ```python theme={null} from standin.smoke import SmokeCheck, run_smoke async def my_checks() -> list[SmokeCheck]: return [ SmokeCheck("model", await model_reachable(), cost="the agent could not answer"), SmokeCheck( "browser", await browser_reachable(), detail="no display", cost="show_page would apologise", required=False, ), ] result = await run_smoke(MyHandler, extra=my_checks) ``` **Mandatory unless declared advisory.** `required` defaults to `True`, so a check you add counts towards `ok` unless you pass `required=False`. Set it false for anything the call itself survives without: a degraded extra is worth reporting and is not worth failing an install over. Give every check a `cost`. It is the sentence printed under a failure, and it is what tells the operator whether to act now or after lunch. ```text theme={null} FAIL browser (no display) without it: show_page would apologise ``` An `extra` that raises is recorded, not fatal. The report gains a non-required `plugin checks` entry carrying the exception text, because a broken check must not take down the report of the call that did work. ## Pointing one at your own listener `SyntheticCall` is the client `run_smoke` uses. It is exposed because a plugin with its own listener may want to point one at that instead. ```python theme={null} from standin.smoke import SyntheticCall call_id = "smoke-local" call = SyntheticCall( f"http://127.0.0.1:{port}/msteams/calling/{call_id}", secret, call_id, frames=10, ) await call.run() print(call.echo_frames) ``` `run()` connects, greets, streams, hangs up, and raises on anything that fails. `echo_frames` is how many audio frames came back. The constructor takes `url`, `secret`, `call_id`, then `frames` (10, clamped to at least 1) and `connect_timeout_s` (5.0). Three things it does that a hand-rolled client usually gets wrong: * **The handshake is signed freshly for each connect.** The listener enforces single use inside the freshness window, so a retry that reused the headers would loop on 401 and read as a wrong secret. * **The `callId` in `session.start` is identical to the one in the path.** A start that disagrees with the authenticated path is refused, and the refusal reads as a wire fault. * **The socket is drained between sends, with a bound.** A blocking read stalls the 20 ms cadence and makes the call look idle to the server's watchdogs. A socket that is never drained leaves the echo count at zero for ever, so "audio came back" could never be proven. Anything it cannot parse is not an echo. The socket is closed in a `finally`, on every path. An error path that skipped the close leaks a client session per run inside a long-lived host that exposes the check. The client lives in the SDK, next to the wire it speaks, deliberately. A copy kept inside a plugin drifts, and the copy that used to exist kept passing against a call path that no longer existed. That is the exact failure a smoke check is for. ## Timeouts The whole run is bounded at `timeout_s`, 15 seconds by default, and the connect at 5. A listener that accepts and then stops talking would otherwise hang CI, and hang a status tool call for ever. A run that times out is a recorded failure, not a hang. The `call` check fails with `the call did not finish within 15 seconds`, and the same sentence becomes `result.error`. ```python theme={null} async def run_smoke( handler_factory: Callable[[], CallHandler], frames: int = 10, extra: Callable[[], Awaitable[list[SmokeCheck]]] | None = None, timeout_s: float = RUN_TIMEOUT_S, # 15.0 ) -> SmokeResult: ... ``` ## In a plugin's own command Wire it to a subcommand and exit on `result.ok`, so CI fails on a bad install. The Hermes plugin does exactly this: ```bash theme={null} hermes msteams-bridge smoke ``` It needs no network, no tenant and no credentials, so it also belongs in the test suite, next to the unit tests. Four frames is enough to prove a round trip and keeps the test well under a second. # Configuration Source: https://docs.komaa.com/python-sdk/configuration Every STANDIN_ variable the SDK reads, the helpers for reading your own, and what a missing extra looks like, in the StandIn Python SDK. The SDK is configured from the environment, and nothing here is read at import: a variable is read when the thing that needs it is built or called, so a worker that never opens a lane never needs that lane's settings. An explicit argument always wins over the environment, which is the rule to reach for whenever the value already lives somewhere better, such as a secret store your host resolved for you. **Python and TypeScript do not treat a blank `STANDIN_CHAT_SECRET` or a bad `STANDIN_PORT` the same way.** Unset `STANDIN_CHAT_SECRET` rather than exporting it empty, and never ship a non-numeric `STANDIN_PORT`. Details are under [Three things that cost an afternoon](#three-things-that-cost-an-afternoon). The TypeScript twin is [Configuration](/typescript-sdk/configuration). ## Everything the SDK reads Every `STANDIN_` variable, in one place, because configuring a deployment out of nine pages is how a deployment ends up half configured. | Variable | What it is for | Owned by | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | | `STANDIN_SECRET` | Your StandIn connection secret. `CallServer` refuses to be built without it, `OutboundCaller` refuses to be built without it, and the chat lane falls back to it. | [CallServer](/python-sdk/call-server) | | `STANDIN_HOST` | Interface the call listener binds. Default `0.0.0.0`. | [CallServer](/python-sdk/call-server) | | `STANDIN_PORT` | Port the call listener binds. Default `9442`. Read with `int()`, so a non-numeric value fails when the server is built. | [CallServer](/python-sdk/call-server) | | `STANDIN_WS_PATH` | Path the call handshake arrives on. Default `/msteams/calling`. | [CallServer](/python-sdk/call-server) | | `STANDIN_CHAT_SECRET` | The key the chat lane signs with, read before `STANDIN_SECRET` and falling back to it. A StandIn AI Teammate app connection issues a separate key for chat; with one key for both lanes, leave this unset. | [Chat](/python-sdk/chat) | | `STANDIN_CHAT_URL` | The chat channel `ChatChannel` dials. It also derives the single origin chat attachments may be fetched from, so there is no second setting to get wrong. | [Chat](/python-sdk/chat) | | `STANDIN_WORKER_URL` | The StandIn control address an outbound call is posted to. StandIn gives it to you with your connection secret. A value that is not http or https, has no host, or carries credentials is refused when `OutboundCaller` is built, not when somebody tries to ring. | [Reaching people](/python-sdk/reaching-people) | | `STANDIN_STATE_DIR` | Where a parked outbound message lives until the person answers, and the default parent of unfinished meeting recaps. Default `~/.standin/state`, created owner-only. Deliberately not a temp directory. | [Reaching people](/python-sdk/reaching-people), [Meeting recap](/python-sdk/minutes#from-the-openclaw-and-hermes-plugins) | | `STANDIN_RECAP_DIR` | Where unfinished meeting recaps sit until they post, expire (24 hours), or hit the retry limit (8). Restart-recoverable local spool. Default `$STANDIN_STATE_DIR/recap`. Best-effort unless this directory is on storage that survives a process or container restart: the default is local process disk, which is ephemeral in most containers. | [Meeting recap](/python-sdk/minutes#from-the-openclaw-and-hermes-plugins) | | `STANDIN_OUTBOUND_ALLOW` | Comma-separated directory ids this agent may ring, read by `OutboundPolicy.from_env()`. Unset means outbound calling is off. | [Reaching people](/python-sdk/reaching-people) | | `STANDIN_OUTBOUND_MAX_PER_HOUR` | Calls placed in any rolling hour, across all targets. Default `6`. Zero means no cap, which is a choice rather than a default. | [Reaching people](/python-sdk/reaching-people) | | `STANDIN_TENANT_ID` | Which organisation a placed call belongs to. Operator configuration only: never the message, the metadata, the model or the caller. | [Reaching people](/python-sdk/reaching-people) | | `STANDIN_VISION_API_URL` | Chat-completions endpoint `FrameDescriber.from_env()` asks about a frame. | [Vision and the avatar](/python-sdk/vision) | | `STANDIN_VISION_MODEL` | The vision model to ask. With the URL unset or this unset, `from_env()` returns `None`, which is the signal a plugin uses to tell an agent that looking is not available here. | [Vision and the avatar](/python-sdk/vision) | | `STANDIN_VISION_API_KEY` | Sent to that endpoint as a bearer token when set. A local endpoint usually needs none. | [Vision and the avatar](/python-sdk/vision) | | `STANDIN_SHOW_ROOTS` | Directories a file may be **shown** from on the bot's tile, separated by `os.pathsep`. Empty means showing files is off. **Python SDK only**: the TypeScript SDK has no document renderer, so it reads no such variable. | [Vision and the avatar](/python-sdk/vision) | | `STANDIN_MEDIA_ROOTS` | Directories a local `MEDIA:` reference may be **read** from, separated by `os.pathsep`. Empty means local files are off. | [Sending a picture](/python-sdk/sending-pictures) | Every variable above except `STANDIN_SHOW_ROOTS` is read by the TypeScript SDK under the same name and with the same meaning, so a deployment that runs both workers configures both the same way. Two of the names are exported as constants rather than written out, in both SDKs, so your code and the SDK cannot drift apart: ```python theme={null} from standin import MEDIA_ROOTS_ENV, TENANT_ENV ``` ### Three things that cost an afternoon * **A blank `STANDIN_CHAT_SECRET` falls through here, and does not in TypeScript.** `ChatChannel` reads the `secret=` argument, then `STANDIN_CHAT_SECRET`, then `STANDIN_SECRET`, and in Python an exported empty string is skipped like an unset one. The TypeScript twin stops at the first variable that is merely **present**, so `export STANDIN_CHAT_SECRET=` shadows a perfectly good `STANDIN_SECRET` there and the channel refuses to construct. Unset the variable rather than blanking it, and the two behave alike. * **`STANDIN_PORT` is read with `int()`.** A value that is not a number raises `ValueError` while `CallServer` is being built, and the message names the bad value rather than the variable it came from, so check a templated value before you export it. The TypeScript twin coerces instead and carries `NaN` as far as `start()`. * **A `STANDIN_WS_PATH` that reduces to `/` is refused** with `ws_path must be a real path such as /msteams/calling`. A worker listening at the root answers anything that reaches it, so this fails at construction rather than on the first call. The two roots variables are separate on purpose, and neither is a substitute for the other: `STANDIN_SHOW_ROOTS` governs what may be drawn onto the tile in a call, `STANDIN_MEDIA_ROOTS` governs what may be uploaded into a chat. Both default to empty, because the paths reaching them were chosen by a model that somebody is steering. ## Reading your own Writing a plugin means reading the same five things every time: a value that must be set, one that may be, a boolean, a check that a vendor host is really that vendor's, and a header map. Use these rather than rolling your own, and the error somebody sees for a missing key reads the same whichever provider they picked. That is the whole reason the module exists: the messages had drifted once already, one wording per provider. ```python theme={null} from standin.config import flag, json_object, optional, required, vendor_host ``` These five are **not** re-exported at the package root in Python: `from standin import required` is an `ImportError`. Import them from `standin.config`. The TypeScript twin differs on both counts: it exports `required`, `optional`, `flag`, `vendorHost` and `jsonObject` from the package root, in camel case. Knowing that here is cheaper than discovering it as a failed import while porting a plugin. | Helper | Signature | Returns | | ------------- | ------------------------------------ | ----------------------------------------------- | | `required` | `required(name, purpose="")` | The value, or raises `StandInError`. | | `optional` | `optional(name, default=None)` | The value, or `default`. Blank reads as absent. | | `flag` | `flag(name, default=False)` | `True` only when the value reads `true`. | | `vendor_host` | `vendor_host(name, default, suffix)` | The host, or raises `StandInError`. | | `json_object` | `json_object(name)` | `dict[str, str]`, `{}` when unset. | Every failure is a `StandInError`, which is at the package root: `from standin import StandInError`. ```python theme={null} api_key = required("ACME_API_KEY", "answer calls with Acme") # unset or blank -> StandInError: ACME_API_KEY is required to answer calls with Acme ``` `purpose` completes the sentence "X is required to ...", so write it as a verb phrase. The point is that the reader learns what they were trying to do, not only which string was empty. ### Why a flag accepts only true `flag` trims and lowercases the value and compares it to the single string `true`. `TRUE` and `True` are therefore true, and `1`, `yes`, `on` and `y` are all **false**. That is deliberately strict, and the direction matters more than the strictness. The settings worth a flag in this SDK are guards, and a guard must not be turned off by a typo. If somebody writes `yes` where `true` was meant, the value reads as false: the guard stays in whatever state the default gives it rather than being switched by a string nobody validated. An unset variable is not a typo, so it returns `default` untouched. ### Why a vendor host is pinned ```python theme={null} host = vendor_host("ACME_HOST", "api.acme.com", ".acme.com") # unset -> "api.acme.com", the default # eu.acme.com -> "eu.acme.com" # acme.com -> "acme.com", the suffix with its dot stripped # api.acme.com.attacker.net -> StandInError: # ACME_HOST must be a acme.com host, got 'api.acme.com.attacker.net' ``` Your API key travels to whatever host the configuration names. A mistyped or injected host is therefore not a failed call, it is credential exfiltration, which is why this raises rather than warns: a warning gets logged after the key has already left. The host is accepted when it equals the suffix with its leading dot stripped, or when it ends with the suffix exactly as you passed it. Pass the suffix **with** its leading dot. `".acme.com"` accepts `api.acme.com` and rejects `evilacme.com`. `"acme.com"` accepts both, because `evilacme.com` ends with it, and that is a guard that reads as present and is not. ### Why a header map is never logged ```python theme={null} headers = json_object("ACME_EXTRA_HEADERS") # {} when unset # anything but a JSON object -> StandInError: ACME_EXTRA_HEADERS must be a JSON object ``` `json_object` parses a JSON object and coerces every key and value to `str`. Anything that is not a JSON object, including a valid JSON array or number, raises the same message. That message names the variable and stops. It never echoes the value, and neither does anything else on the failure path, because this helper exists for header maps: the value carries **your** credentials to somebody else's endpoint. A parse error that pretty-prints the malformed value is a bearer token in a log file, on the one code path most likely to be run with debug logging turned up. ## A missing extra is not a broken install One package holds the SDK and every plugin, so `import standin` sits above code that references frameworks you may never install. Nothing under `standin.plugins` imports its framework while loading, and `import standin` imports no plugin at all. When you do touch one whose framework is absent, you get this rather than a bare traceback from inside somebody else's package: ```python theme={null} from standin import PluginNotInstalled ``` ``` standin.plugins.livekit needs 'livekit', which is not installed. The base standin-sdk install is deliberately dependency-light, so install it with: pip install "standin-sdk[livekit]" ``` It carries `plugin`, `module` and `extra` as attributes, and it subclasses **both** `StandInError` and `ImportError`. The `ImportError` half is the one worth recording: `except ImportError`, `importlib` probes and `pytest.importorskip` all treat a missing extra as a missing import, which is exactly what it is, so a plugin that is absent degrades the same way any optional import does rather than escaping as an unfamiliar error class. It is also raised only when the framework **root** is what went missing. A framework that is installed but broken raises its own `ModuleNotFoundError` for its own dependency, and that propagates untouched: telling somebody to run `pip install "standin-sdk[livekit]"` when LiveKit is already installed sends them to fix something that is not broken. The real extras, from the package manifest: | Install | Pulls in | For | | ----------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pip install standin-sdk` | `aiohttp` | The base install. Answers a real Microsoft Teams call on its own. | | `pip install "standin-sdk[livekit]"` | `livekit-agents` | The LiveKit plugin. | | `pip install "standin-sdk[hermes-agent]"` | nothing | The Hermes plugin. Empty on purpose: Hermes Agent ships its own host and loads the adapter through an entry point, so there is nothing left to fetch. The extra stays declared so the documented install line resolves. | | `pip install "standin-sdk[tile]"` | `pillow` | Encoding your own frames for the avatar tile. | | `pip install "standin-sdk[render]"` | `pypdfium2`, `pillow` | Rendering a document onto the tile. Office documents additionally need LibreOffice on `PATH`, which is not a wheel. | | `pip install "standin-sdk[all]"` | all of the above | Everything. | `tile` and `render` are not plugins, so a missing one does not raise `PluginNotInstalled`. `jpeg_encoder()` returns `None` after logging a line that names the extra, and the tile relay simply does not run while audio is unaffected; rendering a PDF page without the extra raises `StandInError` with `showing a PDF needs the render extra`, followed by that install line. Both are optional because most deployments never put their own video on the tile and never show a file, and a PDF engine in every install is the wrong trade. The SDK requires Python 3.10 or newer. ## Version ```python theme={null} import standin standin.__version__ ``` ```bash theme={null} python -c "import standin; print(standin.__version__)" ``` One string, single-sourced from the package itself and read by the build, so the installed `standin-sdk` distribution and the imported module can never disagree about which version you are running. Quote it in a bug report: the wire protocol is versioned separately, and knowing both is what separates "your worker is old" from "the message was wrong". The TypeScript twin exports its own as `VERSION`. ## Next The listener most of these variables configure. Prove the configuration is right before a real call does. # Consulting and background work Source: https://docs.komaa.com/python-sdk/consulting Delegate real work from a live call without stalling it, and keep a promise to deliver later, in the StandIn Python SDK. A voice model has to answer in under a second or the call sounds broken. Real work does not fit in a second. Looking something up, reading a file, driving a browser, running a skill: those take ten seconds, or five minutes. A caller listening to silence cannot tell thinking from crashed. So an agent that does real work is two agents. The fast one talks. The slow one works. This is the seam between them, and it is the same whichever provider does the talking and whichever framework does the working. ## Hold on, let me check `Consultant` runs the slow agent inside a live call. You supply a factory; what it builds is yours. ```python theme={null} from standin import Consultant consultant = Consultant(lambda: my_agent.run, timeout_s=45) answer = await consultant.ask("what did we bill Contoso last quarter?") ``` `timeout_s` defaults to `DEFAULT_CONSULT_TIMEOUT_S`, 45 seconds: how long a caller will wait on the line before "let me check" stops sounding like thinking and starts sounding like a dropped call. Three behaviours are not obvious, and each exists because a caller is listening. **A blocking agent runs off the loop.** A synchronous callable goes to a thread. Called inline it would hold the event loop for its whole duration, and the caller would hear the call itself stall. **One at a time, and the second asker is told so.** A model that can delegate can delegate twice before the first answer lands. Queueing the second means it waits out both timeouts and answers far too late, so it is refused immediately with a sentence the model can say out loud. `consultant.busy` is that state, if your plugin would rather not offer the tool while one is running. **A timeout is admitted.** The work cannot be cancelled: the thread runs to its end whatever this returns. The answer says it stopped rather than promising a follow-up nothing will send, and the timed-out agent is dropped so the next question builds a fresh one. `ask` never raises. It always returns something speakable, because an exception in a tool result is an agent that goes quiet mid-sentence. ## I'll send you the result When the work will not finish while somebody waits, it becomes a promise, and a promise made on a call that is about to end has to outlive the process that made it. `BackgroundTasks` writes each task to disk **before** the work starts and removes it only once the result has actually been delivered. ```python theme={null} from standin import BackgroundTasks tasks = BackgroundTasks() # during the call task = tasks.remember(query, thread_id=session.start.thread_id) tasks.begin(task) ... tasks.done(task) # once, at worker startup await tasks.resume(lambda: my_agent.run, deliver=post_to_chat) ``` `remember` returns the `BackgroundTask` it wrote, or `None` when it could not be written, which is a task that runs but will not survive a restart. That is worth knowing about and worth continuing with: a non-durable answer still beats no answer. `resume` returns how many tasks it actually delivered, and its `timeout_s` is what each recovered run gets. `BackgroundTasks(directory=..., ttl_s=..., resume_limit=...)` takes all three. The directory defaults to a `tasks` folder under `STANDIN_STATE_DIR`, or under `~/.standin/state` when that is unset, created owner-only. Deliberately never a temp directory: a temp directory passes every test and loses every parked promise on the next reboot, which is invisible until somebody is waiting for an answer that no longer exists. | Field | What it is | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `task_id` | The promise's own id, and the name of its file. | | `query` | What was asked. | | `thread_id` | Where the result goes. A task with none is written, and then dropped loudly the next time `resume` looks, because there is nowhere to send what it produces. | | `session_key` | Which agent session it belongs to, when a framework has one. | | `created_ms` | When the promise was made, which is what the expiry is measured against. | | `claim_path` | Set only on a claimed task, so `finish` can put it **back** on the queue when the delivery fails rather than losing the promise. | The two-phase claim is what makes a restart safe. A task waiting to run is a `.json`; a task being run is a `.claimed`. A process that dies mid-run leaves a claim behind, and the next startup takes it back once it is old enough to be sure nobody is still working on it. A delivery that fails puts the task back rather than dropping it. A promise that outlives the call needs a stated lifetime, so here are the numbers. | Guard | Value | Why | | ------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------- | | `DEFAULT_TASK_TTL_S` | 7200, two hours | An answer long after the question is worse than no answer. | | `DEFAULT_RESUME_LIMIT` | 5 tasks per startup | A restart storm must not fan out one agent per promise across the fleet. What is left stays queued, never discarded. | | `DEFAULT_TASK_TIMEOUT_S` | 300 | What `resume` gives one recovered task's run. Far longer than a consultation, because nobody is listening to it. | | No thread, no task | | A task with nowhere to deliver cannot be kept, so it is dropped loudly at startup rather than run for nothing. | | Ten minute claim window | | Taking a claim somebody is still working on delivers the same answer twice. | Those four constants are not on the `standin` barrel. Reach them as `from standin.consult import DEFAULT_CONSULT_TIMEOUT_S, DEFAULT_TASK_TIMEOUT_S, DEFAULT_TASK_TTL_S, DEFAULT_RESUME_LIMIT`. The TypeScript twin exports all four from the package root, and the three that are durations are named for milliseconds there: `DEFAULT_CONSULT_TIMEOUT_MS`, `DEFAULT_TASK_TIMEOUT_MS`, `DEFAULT_TASK_TTL_MS`. `DEFAULT_RESUME_LIMIT` is a count and keeps its name. ## The two tools Both are `ToolSpec` values, ready to register on `CallTools`. They are not built in, because an agent with no second agent behind it should not be told it has one. ```python theme={null} from standin import BACKGROUND_TASK_TOOL, CONSULT_TOOL tools.register(CONSULT_TOOL, lambda params: consultant.ask(params.get("query", ""))) tools.register(BACKGROUND_TASK_TOOL, start_background_task) ``` Their descriptions are deliberately distinguishable. Without that, a model picks whichever comes first and every long job is run while somebody waits on the line. Once the background tool is registered, pass `background_available=True` to `Consultant` and a timeout will point the caller at it. Leave it off and it will not, because promising a path that does not exist is worse than admitting the work stopped. ## Next Where both tools are registered, beside the built-ins. The other job a `Consultant` is normally handed. When the answer should ring somebody back rather than land in a chat. `STANDIN_STATE_DIR` and every other variable the SDK reads. # Group calls Source: https://docs.komaa.com/python-sdk/group-calls GroupGate, wake phrases, the follow-up window and verbal interrupts for an agent in a Microsoft Teams meeting, in the StandIn Python SDK. A 1:1 call is simple: every turn is for you. A meeting is not. People talk to each other, and an agent that answers every turn of a meeting is worse than one that says nothing, because it has to be muted or removed before the meeting can continue. Two decisions follow from that, and both are taken here rather than by your model: * **who gets answered**, so the agent stays out of a conversation it was not invited into * **what stops playback**, so "stop" stops it whether or not the model would have chosen to Both are taken deterministically in code, on a **finished** caller turn, and they consume the same thing: one transcript, once. Neither is left to the model, for the same two reasons. A model asked "was that for you?" has already been invoked, so a refused turn still costs a round trip and a bill. And a model asked to decide whether to keep talking is a model that is still talking while it decides. ```python theme={null} from standin import ( DEFAULT_FOLLOW_UP_WINDOW_MS, GateDecision, GroupGate, is_addressed, is_meeting_thread, is_verbal_interrupt, ) ``` Every name on this page is exported from `standin`. They live in `standin.gate` if you would rather import the module. ## How the SDK knows it is a group call `is_meeting_thread(thread_id)` is the primary signal, and it is a string test: ```python theme={null} from standin import is_meeting_thread is_meeting_thread("19:meeting_abc@thread.v2") # True is_meeting_thread("8:orgid:0f2a...") # False is_meeting_thread(None) # False ``` Microsoft's thread id for a meeting or a channel conversation begins `19:`. A 1:1 call has no such thread at all, so the presence of one is the signal. It reaches you as `session.start.thread_id`, on every call, before the caller has said a word. See [Call handler](/python-sdk/call-handler#callsession). **A participant count never arrives on the meeting-join path.** A gate keyed on the count alone is dead on exactly the calls it exists for: every meeting the agent is invited to reads as a 1:1, and the agent answers every turn of it. The failure looks like a gate that was never switched on rather than one that was keyed to the wrong field, which is what makes it expensive to find. That is not a hypothetical. The same mistake in the recap path sent every meeting recap to one attendee's private chat instead of to the meeting it summarised, because the count was pinned at 1 there too. See [Meeting recap](/python-sdk/minutes). `note_participants(count)` is the second signal, and it is deliberately weaker: ```python theme={null} from standin import CallSession, GroupGate class MyHandler: def __init__(self) -> None: self._call: CallSession | None = None self._gate: GroupGate | None = None async def on_start(self, session: CallSession) -> None: self._call = session self._gate = GroupGate( wake_phrases=("assistant",), thread_id=session.start.thread_id, ) async def on_context(self, text: str) -> None: # CallServer already parsed the count for you; read it, do not scrape it. count = self._call.participant_count if self._call else 0 if count and self._gate: self._gate.note_participants(count) await self._send_to_model(text) ``` It corroborates and it never overrules. The count is kept as a running maximum, so a count of `1` arriving on a meeting thread adds nothing and takes nothing away: `gate.is_group` stays true. The count is the signal that goes missing, so it may add certainty and must not remove it. `is_group` is true when either signal says so: a meeting thread, or a count of two or more. That covers the group call that is not a meeting thread as well as the meeting whose count never came. Context sentences arrive at `on_context`, which is also where the recording status and keypad presses land. The count behind the sentence is on the session as `participant_count`, so there is nothing to parse out of the prose. See [Call handler](/python-sdk/call-handler#on_context). ## Wake phrases and the follow-up window ```python theme={null} from standin import GroupGate gate = GroupGate( wake_phrases=("assistant", "مساعد"), require_address=True, # default follow_up_window_ms=12_000, # DEFAULT_FOLLOW_UP_WINDOW_MS thread_id=session.start.thread_id, # default "" ) ``` The constructor is keyword only, and one gate belongs to one call: the follow-up window is per-call state. `decide(transcript, now_ms)` returns a frozen `GateDecision` with two fields: | Field | What it means | | ----------- | ---------------------------------------------------------- | | `respond` | Speak an answer to this turn. | | `addressed` | This turn named the assistant. Opens the follow-up window. | ```python theme={null} gate.decide("what do you all think?", 1_000) # respond=False, addressed=False gate.decide("assistant, summarise that", 2_000) # respond=True, addressed=True gate.decide("and the second point?", 7_000) # respond=True, addressed=False gate.decide("anyway, lunch", 20_000) # respond=False, addressed=False ``` The window is why `addressed` is reported separately from `respond` rather than folded into it. A turn inside the window is answered **without having been addressed**, and only an addressed turn reopens the window. Those are different facts about the same turn, and conflating them costs you both: you cannot log who actually named the agent, and a follow-up would keep the floor open forever by being answered. The window is stored as a timestamp rather than a latched boolean. A missed wake phrase therefore self-heals by the clock instead of stranding the agent silent, or talkative, for the rest of the meeting. `now_ms` is yours to supply, and the gate never reads a clock itself. Pass a monotonic source, `time.monotonic() * 1000.0`, so a clock that steps sideways cannot open or close the floor at random. It also makes the whole decision testable without waiting twelve seconds. `require_address=False` turns the gate off entirely and answers everything, on a meeting thread or not. On a 1:1 call the gate returns `respond=True` for every turn, and `addressed` still tells you truthfully whether the caller used your name. That is worth having even when nothing is being gated. ## A gate with no phrase never opens `gate.active` is the question "is this gate muting anything right now?", and it is false unless all three of these hold: the call is a group, `require_address` is on, and **at least one non-blank wake phrase is configured**. ```python theme={null} gate = GroupGate(wake_phrases=(), thread_id="19:meeting_abc@thread.v2") gate.active # False gate.decide("hello", 1_000) # respond=True ``` An empty wake list means the gate is off, on purpose. A gate with nothing that could ever open it would mute the assistant for the whole call, and nothing in the transcript would ever explain why. Silence is the one failure a caller cannot interpret and an operator cannot debug: it looks identical to a dead socket, a crashed worker and an agent that is thinking. A blank or whitespace-only phrase counts as no phrase, because a configuration file with an empty string in it is the usual way this happens. ## Matching a name `is_addressed(transcript, wake_phrases)` is a case-insensitive match on **word boundaries**, not substrings. ```python theme={null} from standin import is_addressed is_addressed("assistant, what is the plan?", ("assistant",)) # True is_addressed("ASSISTANT, hello", ("assistant",)) # True is_addressed("the assistants are ready", ("assistant",)) # False is_addressed("مرحبا مساعد كيف حالك", ("مساعد",)) # True ``` Boundaries rather than substrings because a substring match on a short name fires inside ordinary words, and the way that failure presents itself is the agent interrupting a meeting, which is the exact thing the gate was added to prevent. The boundary is written as "not preceded or followed by a word character", and Python's `\w` is Unicode-aware by default, so an Arabic wake phrase behaves the same as a Latin one and needs no special case in your configuration. Phrases are stripped and lowercased before matching, regex metacharacters in them are escaped, and an empty phrase never matches. Choose a wake phrase that is not an ordinary word in the language the call will be held in. Word boundaries stop `assistant` matching `assistants`; they do not stop a name that happens to be a common noun from matching every time somebody uses it. ## Stop means stop ```python theme={null} from standin import is_verbal_interrupt is_verbal_interrupt("stop by the store") # False is_verbal_interrupt("Stop! Stop!") # True is_verbal_interrupt("ok stop") # True is_verbal_interrupt("Assistant, stop.", ("assistant",)) # True ``` `is_verbal_interrupt(transcript, wake_phrases=())` matches the **whole normalised utterance**, never a substring. "stop by the store" is a sentence somebody said in a meeting, and a substring rule would cut the agent off mid-answer every time it came up. Whole-utterance is the rule that makes this safe to run on every turn. Before the comparison the utterance is normalised: NFKC, lowercased, Arabic diacritics and tatweel removed so a vocalised `تَوَقَّف` matches, and punctuation collapsed to single spaces. Then filler and wake phrases are peeled off **both ends**, repeatedly until nothing changes. "Hermes, please stop" and "hold on, assistant" both reduce to the bare phrase, because the name can sit outside the filler or inside it and both orders are things people say. Pass your wake phrases in, or the name you chose will be the thing that defeats the match. Interrupt phrases ship for four languages: English, Arabic, French and German. `stop`, `wait`, `hold on`, `hold up`, `never mind`, `cancel`, `توقف`, `خلاص`, `arrête`, `ça suffit`, `stopp`, `das reicht` and their close neighbours. The set is a module-private constant in `standin.gate` and there is no hook to extend it, so a phrase your callers use and the set does not is a wrapper around `is_verbal_interrupt`, not a configuration key. One consequence worth knowing before you rely on it: the wake phrase on its own is an address and not a cut, because `"assistant?"` peels down to nothing and nothing never matches. This is the fast path for the unambiguous case, not a replacement for your model's judgement. "could you stop" and "stop talking" are not in the set and return `False`, because widening the rule to catch them is how a set of whole-utterance phrases turns back into substring matching. An interrupt is worth nothing on its own. Pair it with `session.cancel_playback()`: ```python theme={null} async def on_caller_turn(self, text: str) -> None: if is_verbal_interrupt(text, self._wake_phrases): await self._call.cancel_playback() # first: un-send what is queued await self._client.cancel_response() # then: stop generating more return ``` `cancel_playback()` first, because it is the only lever that un-sends audio StandIn already holds. Cancelling the model first stops it producing more, and the caller still hears every buffered sample of the answer they just interrupted. A caller who has said "stop" has stopped listening, and several seconds of the agent carrying on afterwards reads as the agent ignoring them. See [Barge-in](/python-sdk/call-handler#barge-in-and-cancel-playback). Suppress the reply to the interruption itself too. "stop" does not want an answer, it wants silence, and an agent that says "of course, I will stop" has not stopped. ### A request for silence gives the floor back An interrupt cuts the sentence in progress. It does not close the follow-up window, so in a meeting the assistant would still answer the next thing anybody says. That is right after "wait", because whoever said it is about to ask something. It is wrong after "be quiet". `is_dismissal(text, wake_phrases)` tells the two apart, and `gate.close_window()` makes the next turn name the assistant again: ```python theme={null} if is_verbal_interrupt(text, self._wake_phrases): if is_dismissal(text, self._wake_phrases): self._gate.close_window() # "be quiet": wait to be named again await self._call.cancel_playback() await self._client.cancel_response() return ``` Every dismissal is an interrupt, so check it inside the interrupt branch. On a 1:1 call there is no window to close and the call does nothing. ## Where to call it On a finished transcript, once, before you spend a model call. ```python theme={null} import time from standin import is_dismissal, is_verbal_interrupt async def on_caller_turn(self, text: str) -> None: """The provider reported a finished caller transcript.""" # Interrupts first: "stop" is not a turn to answer, and scoring it for # wake phrases can only end in answering it. if is_verbal_interrupt(text, self._wake_phrases): if is_dismissal(text, self._wake_phrases): self._gate.close_window() await self._call.cancel_playback() await self._client.cancel_response() return decision = self._gate.decide(text, time.monotonic() * 1000.0) if not decision.respond: await self._client.cancel_response() return await self._client.create_response() ``` Three things about that order and that placement. **Interrupts before the gate.** Run the gate first and "stop" is scored as an ordinary turn: inside the follow-up window it is answered, and "of course, I will stop" is the one reply a caller who said stop must never hear. **Finished transcripts, not partials.** A partial has not necessarily reached the name yet, so a gate run on partials refuses turns it would have accepted a syllable later. If you want the window stamped as early as possible, guard the partial path with `is_addressed` and let only that call `decide`: because the window is a timestamp, a stamp can only ever open the floor, never close it. ```python theme={null} def _on_caller_transcript(self, text: str, final: bool) -> None: if not final: if is_addressed(text, self._gate.wake_phrases): self._gate.decide(text, time.monotonic() * 1000.0) return self._last_decision = self._gate.decide(text, time.monotonic() * 1000.0) ``` **Before the model call, not after.** A refused turn should cost nothing. If your provider answers on its own voice detection, put it in manual response mode while `gate.is_group` is true and create the response yourself when `decision.respond` is true. A response cancelled after it has started generating is one the caller has already heard the beginning of, and a meeting hears the agent start to speak and then stop, which is more disruptive than either answering or staying quiet. There is one thing to check before any of this: whether your provider gives you a caller transcript at all. Without one there is nothing to gate on and nothing to match an interrupt against. Log that loudly at startup rather than shipping a gate that silently never fires, and leave auto-response on, because an ungated assistant is at least an assistant. ## What is not the same in the TypeScript SDK The gate itself is. `GroupGate`, `GateDecision`, `isAddressed`, `isMeetingThread`, `isVerbalInterrupt` and `DEFAULT_FOLLOW_UP_WINDOW_MS` exist there under the same names in camelCase, with the same twelve second default, the same thread-id rule and the same "no phrase means no gate" catch. Porting a gate between the languages changes the casing, and one thing that is language-shaped rather than a decision: the constructor takes keyword arguments here and a single options object there. Two things around it are not the same, and both will bite a port that assumes otherwise. **The interrupt phrase set differs by language and by membership.** This SDK ships English, Arabic, French and German; the TypeScript SDK ships English and Arabic. The lists are not translations of each other either: `"cancel"`, `"stop stop"` and `"hold up"` match here and not there, and `"stop talking"`, `"hang on"`, `"pause"` and `"one second"` match there and not here. The TypeScript implementation also caps the reduced utterance at four words as a second guard against an accidental match; this one has no such cap and leans entirely on the phrase set being exact. Test the phrases your callers actually use, in the SDK you are actually running, rather than porting a test suite across. **The echo guard is a different shape entirely.** This SDK has an `EchoGuard` class that owns its own playout clock, and because that clock is monotonic milliseconds, a single `time.monotonic() * 1000.0` serves both the gate and the guard on the same call. The TypeScript SDK has a `shouldSuppressEcho` function, no class, and a playout horizon you keep yourself: it must be on the epoch-millisecond scale, so there the shared source has to be `Date.now()` and the monotonic option the gate would otherwise accept stops the guard firing at all. [Realtime providers](/python-sdk/realtime-providers) spells the difference out in full. ## Next The startup buffer and the echo guard, for a speech-to-speech agent. For an agent that is not speech to speech: segmentation, WAV and paced playback. Where the transcript, the context and `cancel_playback` reach you. The other decision the meeting thread id makes for you. # Meeting recap Source: https://docs.komaa.com/python-sdk/minutes Minutes of the call, including what was shown on screen, with a Word document, in the StandIn Python SDK. A recap is the one thing people ask an agent for that it cannot do while the call is happening. It needs the whole conversation, so it happens at the end, and by then the caller has usually gone. What makes this one worth having is the second track. Every transcript-first recap tool is blind to the screen share. Your agent was on the call and could see it. ## Keeping the record Feed the transcript as the call runs, from whichever callbacks your plugin already has. ```python theme={null} from standin import Transcript transcript = Transcript() transcript.add(caller_name, "we should push the launch to March") transcript.add("Assistant", "noted", role="assistant") transcript.add_visual("Sara's shared screen: the Q3 revenue dashboard") ``` `role` is `"caller"` unless you say otherwise, and it is what tells the two sides apart in the document. A consecutive turn from the same speaker **and** the same role is merged into the one before it, because a live transcript arrives as fragments and half-sentences fed to a model as separate turns make the minutes read like a stutter. Merging never crosses speakers: filing one person's words under another's name is worse than no attribution, because it is confidently wrong. Both tracks are bounded, because a two-hour meeting sits in the memory of a process that is also carrying live audio. What survives is the tail, since the end of a meeting is what minutes are mostly about. Here are the numbers, so "bounded" is something you can check: | Bound | Value | What it limits | | ---------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MAX_TRANSCRIPT_TURNS` | 600 | Turns held at once. | | `MAX_TRANSCRIPT_VISUALS` | 60 | Things shown. Far fewer than turns, because a screen changes slowly. | | `MAX_TRANSCRIPT_ENTRY_CHARS` | 1000 | How long one entry may grow before the next turn from the same speaker starts a fresh one. Without it, an hour of one person talking coalesces into a single ever-growing entry the entry count can never trim. | | `MAX_TRANSCRIPT_ENTRIES` | 40 | Entries that reach the summarising model. | | `MAX_TRANSCRIPT_CHARS` | 12000 | Characters that reach it. | | `RECAP_MIN_TURNS` | 4 | Below this there is no meeting to summarise, only a greeting. Advisory: it is exported for your plugin to check before it offers a recap at all. `post_minutes` itself only refuses an **empty** transcript, so a two-turn call still gets summarised if you ask. | `MAX_TRANSCRIPT_ENTRIES`, `MAX_TRANSCRIPT_ENTRY_CHARS` and `RECAP_MIN_TURNS` are on the `standin` barrel. The three hard holding caps are `from standin.minutes import MAX_TRANSCRIPT_TURNS, MAX_TRANSCRIPT_VISUALS, MAX_TRANSCRIPT_CHARS`. Consecutive visual repeats are collapsed. The vision lane describes whatever is on screen each time it is asked, and a slide nobody changed would otherwise fill the record with the same line. ## Where the minutes go Decided **once**, before anything is written, and then passed to every later step. `resolve_minutes_target` returns a `DeliveryTarget`: a `kind` of `"thread"` or `"caller-dm"`, a `conversation_id` and a `tenant_id`, and nothing else. `caller_chat` comes from a [`PersonalChats`](/python-sdk/chat#remembering-who-messaged-you) you construct once and share with your `ChatChannel`, so the chat lane fills it in as messages arrive: ```python theme={null} from standin import ChatChannel, PersonalChats chats = PersonalChats() chat = ChatChannel(respond=on_message, chats=chats) ``` Without one, a one-to-one call resolves to no target and gets no minutes, which is the correct outcome rather than a bug: there is no conversation that can be asserted as that caller's. Both lanes have to run in one process for this to work. ```python theme={null} from standin import resolve_minutes_target target = resolve_minutes_target( thread_id=session.start.thread_id, human_count=participants, caller_aad_id=session.start.caller.aad_id, caller_chat=chats.for_caller( caller_aad_id=session.start.caller.aad_id, tenant_id=tenant ), session_tenant_id=session.start.tenant_id, config_tenant_id=configured_tenant, ) ``` This is the highest-consequence rule in the feature. A send with no pinned recipient falls back to whatever conversation the sending code last saw, and a customer's meeting minutes are the most sensitive thing this product produces. So the target is one immutable value, and no step downstream is allowed to work out a recipient of its own. Three details are load-bearing, and each one is a real failure that happened: **A meeting thread counts even when the participant count says one.** The count only arrives on topologies that send a participants frame, so on some deployments it stays pinned at 1. A count-only test delivered the minutes of a group call into one attendee's private chat. **The caller's own chat is admitted by scope, never by the shape of its id.** A personal chat and a group thread are told apart by what the message said its scope was. Testing the id prefix inverts it, admitting a team channel and rejecting every real private chat. **The tenant is the session's, then the configured one, then the remembered sender's.** Never the caller's own. A guest's tenant describes the organisation they came from, which is not the one this worker is bound to, and it is the one plausible-looking source that is actively wrong. A call that identifies nobody gets no target, and therefore no minutes. That is the correct outcome: there is no conversation that can be asserted as theirs. Pass a list when more than one place is admissible, best first. The walk advances **only on a 404**, because that is the only answer that proves nothing was delivered, so it is the only one where trying again cannot duplicate. A 401 or a 5xx would fail identically elsewhere. The SDK's own chat lane writes to a socket and gets no status back, so it can report that a message was sent and never that it was refused. A poster built on it never advances the walk, which is the safe behaviour rather than a bug. Supply a poster that surfaces a status if you want the fallback to fire. ## Writing it up ```python theme={null} from standin import post_minutes result = await post_minutes( consultant.ask, transcript, target, deliver=post_to_chat, document_dir=Path("~/standin/minutes").expanduser(), subtitle=f"Call with {caller} - ~{minutes} min, {humans} human participants.", ) await self.lane.say(result.spoken) ``` `document_dir` decides whether a Word document is written at all: leave it out and none is. `subtitle` is the line under the document's title, and `assistant_label` and `caller_label` name the two sides in the attributed transcript, defaulting to `"Assistant"` and `"Caller"`. `result.spoken` goes back through whatever makes your provider talk. There is no `session.say`: `CallSession` carries `send_audio` and nothing that synthesizes. `lane.say` above is a [`VoiceLane`](/python-sdk/voice#saying-a-line-nobody-asked-for); a realtime plugin hands the sentence back as the tool result, which is what the model then reads out, and a handler assembling its own pipeline synthesizes it and plays it through [`PacedPlayback`](/python-sdk/voice#playing-it-back) like any other line. `post_minutes` never raises. It normally runs during teardown, where an exception takes the whole teardown with it, so every failure comes back as a sentence instead. It degrades a step at a time: a document that cannot be built sends text only, a send that fails is logged, and the spoken sentence is always there. Two outcomes look similar and are kept apart on purpose. A call with nothing said is told it was too short. A call with plenty said but no conversation to post into is told exactly that. Conflating them tells people their conversation did not count when it did. `RecapResult` is what comes back. | Field | What it holds | | ----------- | ----------------------------------------------------------------------------------------- | | `spoken` | One sentence for the agent to say. Always present, including on failure. | | `minutes` | The minutes themselves, so a caller can do something else with them when the post failed. | | `document` | Where the Word document was written, when one was. | | `delivered` | Whether the minutes actually reached the chat. | | `target` | Which of the admissible conversations took them. | Your poster may answer with a plain `True` or with a `PostOutcome`, which is `ok` plus the `status` behind it. Both are read correctly, and `from standin.minutes import PostOutcome` is where it lives, because it is not on the `standin` barrel. Do not hand back an object of your own and expect it to be truth-tested: every object is truthy, and a post StandIn rejected would report as delivered. Only a real `bool` is read as one. ## The document `write_minutes_docx` emits a Word-openable `.docx` with no dependencies. A document format library would be a dependency every install pays for so that the small fraction who ask for minutes get a file, which is the wrong trade for an SDK. Give it `sections` from `parse_minutes_sections`, a list of `MinutesSection`, and it renders real headings instead of a flat wall of lines. A model asked for `### Key points` will write `## Key points` or `**Key points:**` depending on the model and the day, so all of those are accepted; a section that ends up with nothing in it is left out entirely rather than printed as a heading over white space. Give it `transcript` and it appends an attributed transcript, which is the half a transcript-only tool cannot produce: the call gave you the real speaker per utterance. A turn that already carries its own `Name:` prefix is written through untouched, because relabelling it as a generic caller destroys the attribution and double-prefixing reads as a transcription fault. `has_speaker_prefix` is that test, exported so your own writer can make the same decision. Delivery to the chat is text. The document is written to disk beside it, for whoever keeps the record. A meeting chat cannot be sent a file by a bot the way a person can, so a document promised into the chat would be a promise that quietly fails. Say so in the message rather than leaving it as a silent difference: `DOCUMENT_NOT_ATTACHED` is that sentence. ## The prompt `minutes_prompt` asks for Key Points, Decisions, Action Items and, when anything was shown, Presented. The instruction not to infer what was on screen is the load-bearing one. A model handed "Sara shared a dashboard" will happily invent the numbers on it, and minutes that invent numbers are worse than minutes with a gap. `is_summary_request` recognises somebody asking for the write-up in conversation. It needs both halves: "summarise" alone is asked about a document, an email, or a page the agent is looking at. `MINUTES_TOOL` is the tool declaration, ready to register on `CallTools`. It is named `post_meeting_minutes`, not after the function that does the work, because a model handed two things with one name cannot tell which it is calling. It is not built in: whether there is anywhere to post depends on the call, and `resolve_minutes_target` is what answers that. Answer the model the moment it calls, then run the recap. A tool call with no result stalls the turn, and the recap is a full model run plus a document write plus a send, so the caller sits in silence wondering whether anything is happening. ## From the OpenClaw and Hermes plugins You do not have to wire `post_minutes` yourself. Each plugin has a switch (`meeting_recap` / `meetingRecap`), off by default. When it is on, hang-up writes a **restart-recoverable local spool** first (`STANDIN_RECAP_DIR`, else `STANDIN_STATE_DIR/recap`, else `~/.standin/state/recap`) and posts after the call is already gone. A process restart drains unfinished jobs if that directory survives the process. Treat recap as best-effort unless that directory is on storage that survives a process or container restart: the default path is local process disk, which is ephemeral in most containers. The file stays until the minutes post, the job expires (24 hours), or it hits the retry limit (8). OpenClaw always opens its own **listen-only** chat lane for recap and never answers on it. Hermes reuses the host `respond` handler when one exists, and opens a listen-only lane only when it does not. The host must provide an outbound chat lane and a summarization consult. Hang-up does not await the consult. Meeting recaps are best-effort. They may be lost if the worker exits during processing. Delivery needs StandIn's outbound chat socket, which is the [StandIn AI Teammate app](/tiers#the-standin-ai-teammate-app). A Free / bring-your-own-bot identity answers chat on its own Azure Bot messaging URL, not this socket. Plugin pages: [Hermes Agent](/python-sdk/plugins/hermes-agent#meeting-recap). The TypeScript twin is [OpenClaw](/typescript-sdk/plugins/openclaw#meeting-recap). ## Next `PersonalChats`, and the lane the minutes are posted down. The slow agent that writes the recap without stalling the call. # Python SDK Source: https://docs.komaa.com/python-sdk/overview Put your AI agent into a Microsoft Teams call. The shared core every StandIn Python plugin builds on. `standin-sdk` is the worker side of a StandIn call. StandIn handles the Microsoft Teams side and talks to your worker over one authenticated WebSocket per call. This package is that socket's other end. You write a handler. The SDK does everything else on the wire. ```bash theme={null} pip install standin-sdk ``` One package, imported as `standin`, and every plugin ships inside it. That line alone is already enough for [ElevenLabs](/python-sdk/plugins/elevenlabs), [Deepgram](/python-sdk/plugins/deepgram) and [Cartesia](/python-sdk/plugins/cartesia): they are reached over an ordinary WebSocket, so they need nothing beyond aiohttp. A capability lands once in the core and reaches every plugin, which is why there is no second package to keep in step. ## The four extras An extra is either a framework that runs inside your process or a capability that needs a wheel most deployments do not want. Both kinds are off by default, and the two capability extras are designed to stay off rather than to fail. | Extra | Install | What it adds | Without it | | -------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `livekit` | `pip install "standin-sdk[livekit]"` | `livekit-agents`, for the in-process LiveKit plugin. | Importing the plugin raises `PluginNotInstalled` naming the extra. | | `hermes-agent` | `pip install "standin-sdk[hermes-agent]"` | Nothing, deliberately. Hermes ships its own host and loads the adapter through an entry point, so there is nothing left for pip to fetch. The extra stays declared so the documented install line resolves. | The adapter is already in the base package. | | `tile` | `pip install "standin-sdk[tile]"` | Pillow, to encode frames of your own video for the bot's tile. | The tile relay logs one line and stays off. The caller hears the agent and sees StandIn's own avatar, which is what they would have seen anyway. | | `render` | `pip install "standin-sdk[render]"` | `pypdfium2` and Pillow, to rasterise a PDF page onto the tile. Office documents additionally want LibreOffice on `PATH`, which is not a wheel. | Showing a file answers with a sentence naming the missing renderer, which the model reads out instead of promising something it cannot do. | `pip install "standin-sdk[all]"` takes all three wheels at once. Plugins are resolved on first touch and never at `import standin`, so the base install is aiohttp and nothing else. `standin.plugins.livekit` is the stable spelling, because the path says which layer a name comes from; `standin.livekit` works as a shorthand and is not the documented one. ## How it fits together ```mermaid theme={null} flowchart LR T["Microsoft Teams call"] <--> S["StandIn hosted service"] S <-->|"HMAC-authenticated WebSocket, PCM16 16 kHz"| W["Your worker: standin-sdk + plugin"] W <--> A["Your AI agent"] ``` One call is one socket. StandIn dials `wss:///msteams/calling/{callId}`, your `CallServer` answers it, verifies the handshake, and builds one handler for that call. When the call ends, the handler is closed and the slot is freed. ## What the SDK owns, and what you own Everything that is the same whichever agent framework you use belongs to `CallServer`. That split is why a StandIn plugin is small. | The SDK owns | Your code owns | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | | The socket StandIn dials, and the HMAC handshake with its single-use replay guard | What the caller's voice means | | Capacity, draining, and the one-live-session-per-`callId` rule | Where the reply audio comes from | | The wire protocol: `session.start`, `audio.frame`, `video.frame`, `ping`, `participants`, `dtmf`, `recording.status`, `assistant.say`, `session.end` inbound, and `audio.frame`, `assistant.cancel`, `expression`, `speech.marks`, `display.image`, `display.frame`, `pong`, `session.end` outbound | When to interrupt a turn | | Outbound sequence numbers and the audio timeline | Your model, your prompt, your tools | | Five timers: the pre-start watchdog, the caller-audio idle watchdog, the `on_start` timeout, the stale-call reaper and the optional call-duration ceiling | Your own cleanup in `aclose` | | Idempotent, shielded teardown that always frees the slot | | A handler never tracks a sequence number, never builds a frame, and never sees a signature. ## The three things you import Answers the dial, authenticates it, speaks the wire protocol, drives one handler per call. The five-method seam your plugin implements. Every method is optional. The Microsoft Teams messages lane. Dialed out from your worker, so chat needs no listener and no bot credential. ## The whole contract ```python theme={null} from standin import CallServer, CallSession class EchoHandler: async def on_start(self, session: CallSession) -> None: self._call = session async def on_caller_audio(self, pcm: bytes) -> None: await self._call.send_audio(pcm) # PCM16, 16 kHz, mono server = CallServer(handler_factory=EchoHandler) await server.start() ``` `CallHandler` is a `typing.Protocol`, not a base class. Nothing inherits from anything, a missing method is a no-op, and a synchronous method is accepted where there is nothing to await. ## The whole surface, by lane Those three names are the seam. Everything else in the package is a lane you can ignore until you need it. Every name below resolves from `import standin` unless the row says otherwise, and the page in the last column is the lane's page rather than a per-name reference: it is where the reasoning lives, and this table is how you find which page that is. | Lane | What is on the barrel | Page | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | The call seam | `CallServer`, `CallHandler`, `CallSession`, `HandlerFactory`, `SpeakerHandler`, `VideoHandler`, `StandInError`, `PluginNotInstalled`, `__version__` | [CallServer](/python-sdk/call-server), [Call handler](/python-sdk/call-handler) | | Audio | `FrameAligner`, `resample_pcm16`, `frame_duration_ms`, `pcm16_rms`, `FRAME_BYTES`, `FRAME_MS`, `SAMPLE_RATE_HZ`, `REALTIME_SAMPLE_RATE_HZ`, `BYTES_PER_SAMPLE`, `NUM_CHANNELS` | [Audio](/python-sdk/audio) | | Turn-taking | `VoiceLane`, `VoiceTurn`, `UtteranceSegmenter`, `PacedPlayback`, `Playback`, `decode_wav`, `encode_wav`, the `TROUBLE_` sentences | [Turn-taking](/python-sdk/voice) | | Realtime providers | `StartupBuffer`, `EchoGuard`, `ECHO_SUPPRESSION_WINDOW_MS`, `ECHO_BARGE_IN_RMS`, `MAX_PENDING_AUDIO`, `MAX_PENDING_CONTEXT` | [Realtime providers](/python-sdk/realtime-providers) | | Group calls | `GroupGate`, `GateDecision`, `is_addressed`, `is_meeting_thread`, `is_verbal_interrupt`, `DEFAULT_FOLLOW_UP_WINDOW_MS` | [Group calls](/python-sdk/group-calls) | | Vision | `VideoFrame`, `parse_video_frame`, `FrameDescriber`, `display_image`, `display_frame`, `VisionTools`, `KeyframeStore`, `VisionBudget`, `PageRenderer`, `AmbientVision` | [Vision and the avatar](/python-sdk/vision) | | The avatar | `expression`, `infer_emotion`, `speech_marks`, `SpeechMark`, `ExpressionCue`, `EMOTIONS`, `TurnLipSync`, `estimate_visemes`, `visemes_from_alignment`, `TileStream`, `jpeg_encoder` | [The avatar](/python-sdk/avatar) | | Call tools | `CallTools`, `ToolSpec`, `tool_schemas`, `BUILT_IN_TOOLS`, `SHOW_PAGE_TOOL` | [Call tools](/python-sdk/call-tools) | | Consulting | `Consultant`, `BackgroundTasks`, `BackgroundTask`, `CONSULT_TOOL`, `BACKGROUND_TASK_TOOL` | [Consulting](/python-sdk/consulting) | | Meeting recap | `Transcript`, `post_minutes`, `resolve_minutes_target`, `minutes_prompt`, `write_minutes_docx`, `RecapResult`, `DeliveryTarget`, `MINUTES_TOOL` | [Meeting recap](/python-sdk/minutes) | | Chat | `ChatChannel`, `InboundMessage`, `parse_inbound`, `build_reply`, `PersonalChats`, `PersonalChat` | [Chat](/python-sdk/chat) | | Attachments | `build_chat_turn`, `ChatTurn`, `ChatImage`, `ChatAudio`, `Transcriber`, `fetch_chat_images`, `fetch_chat_audio`, `transcribe_voice_messages`, `spool_clip` | [Attachments in chat](/python-sdk/chat-attachments) | | Sending a picture | `outbound_image`, `OutboundImage`, `sniff_image_type`, `sanitize_image_name`, `parse_media`, `load_media`, `media_roots`, `AgentMedia` | [Sending a picture](/python-sdk/sending-pictures) | | Reaching people | `OutboundCaller`, `OutboundPolicy`, `OutboundLane`, `PendingMessages`, `VoiceDelivery`, `LiveCalls` | [Reaching people](/python-sdk/reaching-people) | | Security | `sign_handshake`, `verify_handshake`, `sign_body`, `verify_body`, `sign_request`, `canonical_request` | [Security](/python-sdk/security) | | Configuration | Not on the barrel: `from standin.config import required, optional, flag, json_object, vendor_host` | [Configuration](/python-sdk/configuration) | | Checking the install | `run_smoke`, `smoke_report`, `SyntheticCall` | [Checking the install](/python-sdk/checking-the-install) | ## The two SDKs are one API The wire protocol, the audio format and the HMAC are byte-identical between the languages, and shared conformance vectors prove it rather than promising it: both protocol modules are generated from one schema and gated against drift. The core seam is the same in both. Same handler methods, same session, same watchdogs, same defaults. A handler ported between the languages changes only its method names: Python is snake\_case, TypeScript is camelCase. Around the seam a few peripheral helpers exist in one language only, and the page each one belongs to says so. Three that matter here: * **Timers are seconds in Python and milliseconds in TypeScript.** `pre_start_timeout=10.0` against `preStartTimeoutMs: 10000`. The names differ too, so a straight port does not compile, but a configuration file copied between two workers does not complain. * **The two optional video callbacks sit differently.** `on_video_frame` and `on_speaker_change` live on separate `VideoHandler` and `SpeakerHandler` protocols here, because `CallHandler` is `runtime_checkable` and a sixth member would break `isinstance` for every handler written before the vision lane existed. In TypeScript every handler member is optional, so they are simply two more of them. * **Who may call the agent is a TypeScript module.** `isInboundCallAllowed` and its companions ship in that SDK only. A Python worker writes the same check in its own handler, which is the [refusing a call](/python-sdk/call-handler#refusing-a-call) snippet. ## Why the plugin lists differ per language Most plugins exist in both languages, and the ones that do not are settled by the same rule: **which language a plugin lives in is decided by the framework it integrates, not by preference.** ElevenLabs, Deepgram, Cartesia and LiveKit are in both trees. [Hermes Agent](/python-sdk/plugins/hermes-agent) has to be Python, because Hermes loads the adapter into its own process. [OpenClaw](/typescript-sdk/plugins/openclaw) is TypeScript for the same reason in reverse, and [OpenAI Realtime](/typescript-sdk/plugins/openai) is a TypeScript plugin today. Nothing is missing from either side of the wire: both SDKs speak the same protocol, run the same conformance vectors, and answer the same calls. ## Where the SDK stops Three boundaries, and each is a real line rather than a disclaimer. **Speech and reasoning are your framework's.** Speech recognition, speech generation and the model that decides what to say belong to your provider. The SDK carries PCM in both directions and gives you the turn-taking pieces if you are assembling three vendors yourself, but nothing in this package transcribes, synthesizes or thinks. **The Microsoft side is StandIn's.** StandIn handles it. Your worker never holds a Bot Framework credential and never talks to Microsoft. **StandIn draws the avatar tile; the SDK sends it hints.** This is the boundary most often described wrongly, so it is worth stating exactly. The SDK does carry the vision and avatar lane: `session.express` names an emotion, `session.send_speech_marks` sends the viseme timeline that drives lip-sync, `session.display_image` puts a picture on the tile, `session.send_tile_frame` puts one frame of your own video on it, and `session.latest_video_frame` and `CallHandler.on_video_frame` are how the caller's camera and screen share reach you. Whole modules back those: `avatar`, `lipsync`, `vision`, `vision_tools`, `tile` and `ambient`, all on the barrel above. What the SDK does not do is render anything. It never composites a face, never encodes the avatar and never knows how the tile is drawn. See [Vision and the avatar](/python-sdk/vision) and [The avatar](/python-sdk/avatar). A message type this SDK does not recognise is ignored by contract, which is what lets an older worker and a newer StandIn interoperate. That rule is about unknown frames, not about the avatar: those are frames the SDK itself sends. ## Audio format PCM16, 16 kHz, mono, little-endian, in both directions. `on_caller_audio` receives it and `send_audio` expects it back. Realtime models usually speak 24 kHz, which is why the SDK ships a resampler and a frame aligner. See [Audio](/python-sdk/audio). ## Requirements * Python 3.10 or newer * `aiohttp` 3.9 or newer, the only runtime dependency * A StandIn identity and its connection secret, from [standin.komaa.com](https://standin.komaa.com) * A public `wss://` route to your worker, so StandIn can dial in ## Next Install the package, run the echo plugin, and hear yourself on a real Microsoft Teams call. The five methods, one at a time, with a full worked handler. `resample_pcm16`, `FrameAligner`, and the 16 kHz against 24 kHz problem. The HMAC handshake, the two signing lanes, and what the SDK refuses. # Cartesia plugin Source: https://docs.komaa.com/python-sdk/plugins/cartesia Answer Microsoft Teams calls with a Cartesia Line agent, using the StandIn Python SDK. `standin.plugins.cartesia` puts [Cartesia](https://docs.cartesia.ai/line) on a real Microsoft Teams call. StandIn answers the call and dials your worker; this plugin answers that dial, opens one session per call, and relays the audio both ways. ## Install and run There is nothing to install beyond the SDK. Cartesia is reached over an ordinary WebSocket, so this plugin adds no dependency and needs no extra. ```bash theme={null} pip install standin-sdk ``` ```bash theme={null} export STANDIN_SECRET=... # your StandIn connection secret export CARTESIA_API_KEY=... export CARTESIA_AGENT_ID=... python -m standin.plugins.cartesia ``` Expose port `9442` at the `/msteams/calling` path and register the public `wss://` URL as your StandIn identity's agent voice URL. The full walkthrough is in the [Quickstart](/python-sdk/quickstart), and there is a runnable example at [examples/cartesia-msteams-connector](https://github.com/komaa-com/standin/tree/main/examples/cartesia-msteams-connector). ## The audio format Audio is pinned to `pcm_16000` in both directions. That is exactly what a Microsoft Teams call carries, so nothing resamples anything. ## Your agent stays your agent Cartesia runs the agent, so this plugin is transport and nothing else. There are no call tools to declare here, because whatever the agent does it does in its own code on Cartesia's platform. What reaches that code: Stream metadata: `callId`, `callerName`, `tenantId`, `direction`. `custom` events: participant counts, recording changes, the closing line. Real `dtmf` events, read back out of the SDK's own context sentence. `CARTESIA_SYSTEM_PROMPT` is optional on purpose. Left unset, the agent keeps exactly the prompt you wrote on Cartesia's side. Set it and the caller's details are appended to yours. Nothing here ever silently rewrites a deployed prompt. ## Inside your own worker The plugin is a `CallHandler` like any other, so you can build it yourself instead of running the module: ```python theme={null} from standin import CallServer from standin.plugins.cartesia import CartesiaConfig, CartesiaHandler config = CartesiaConfig.from_env() server = CallServer(handler_factory=lambda: CartesiaHandler(config)) await server.start() ``` Read the configuration **once**, then close over it. `handler_factory` runs once per call, so `handler_factory=CartesiaHandler` would call `CartesiaConfig.from_env()` again on every call: a key removed from the environment after startup would then fail the next caller instead of failing you. `server.start()` returns as soon as the listener is bound, so a script that ends there exits before a single call arrives. `serve()`, which is what `python -m standin.plugins.cartesia` runs, is the sample above plus an `await asyncio.Event().wait()` to hold the process open and an `await server.aclose()` in a `finally`, which drains live calls and releases the port on the way out. ## Configuration | Variable | What it is | | ------------------- | ---------------------------------------------------------------------------------------------------------------- | | `CARTESIA_API_KEY` | Your API key. Used only to mint a per-call token over HTTPS, so the long-lived key never rides the agent socket. | | `CARTESIA_AGENT_ID` | Which Line agent answers the call. | | Variable | What it is | | ------------------------ | ------------------------------------------------------------------------------------------------------- | | `CARTESIA_VOICE_ID` | Override the agent's configured voice. | | `CARTESIA_INTRODUCTION` | What the agent says first. | | `CARTESIA_SYSTEM_PROMPT` | Appended to, never replacing, the prompt on Cartesia's side. Leave it unset and nothing is added. | | `CARTESIA_VERSION` | API version header. Defaults to `2025-04-16`. Pin it when Cartesia ships a version you have not tested. | | `CARTESIA_API_HOST` | Defaults to `api.cartesia.ai`. Must be a cartesia.ai host. | The configuration is read once when the worker starts, not per call, so a missing key stops the worker at startup rather than surprising the first caller. `CARTESIA_API_HOST` is checked against the cartesia.ai suffix, because your API key travels to it: a wrong host would be credential leakage rather than a failed call. The same shape applies to every plugin, and the reasoning is on [Configuration](/python-sdk/configuration#why-a-vendor-host-is-pinned). ## Next The startup buffer, the echo guard and barge-in that every provider plugin sits on. Every `STANDIN_` variable, and the helpers for reading your own. # Deepgram plugin Source: https://docs.komaa.com/python-sdk/plugins/deepgram Answer Microsoft Teams calls with a Deepgram Voice Agent, using the StandIn Python SDK. `standin.plugins.deepgram` puts [Deepgram](https://developers.deepgram.com/docs/voice-agent) on a real Microsoft Teams call. StandIn answers the call and dials your worker; this plugin answers that dial, opens one session per call, and relays the audio both ways. ## Install and run There is nothing to install beyond the SDK. Deepgram is reached over an ordinary WebSocket, so this plugin adds no dependency and needs no extra. ```bash theme={null} pip install standin-sdk ``` ```bash theme={null} export STANDIN_SECRET=... # your StandIn connection secret export DEEPGRAM_API_KEY=... python -m standin.plugins.deepgram ``` Expose port `9442` at the `/msteams/calling` path and register the public `wss://` URL as your StandIn identity's agent voice URL. The full walkthrough is in the [Quickstart](/python-sdk/quickstart), and there is a runnable example at [examples/deepgram-msteams-connector](https://github.com/komaa-com/standin/tree/main/examples/deepgram-msteams-connector). ## The audio format Audio is pinned to `linear16` at 16 kHz in both directions when the session starts. That is exactly what a Microsoft Teams call carries, so the hot path is a copy and nothing resamples anything. ## What the agent can do about the call These are declared automatically when the call starts, so there is nothing to set up on the provider's side. Nothing to implement: the plugin answers them. | Tool | Parameters | What it does | | ------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------- | | `end_call` | none | Hang up. | | `express` | `emotion` | Set the avatar's expression on the bot's tile. See [The avatar](/python-sdk/avatar#the-emotion-the-face-wears). | | `show_image` | `url`, `caption?`, `display?` | Put a picture on the bot's video tile. | | `look` | `source?`, `question?` | Look at the caller's screen share or camera. | | `look_back` | `question?` | Look again at something the caller has already moved past. Recorded calls only. | `show_image` takes a public `url`, fetched through the SDK's guard: public hosts only, no private or link-local addresses, and the address is re-checked at connect time to close the DNS rebind window. `display` is `fullscreen` or `overlay`, and this plugin routes the call through `CallTools.dispatch`, so the model's choice is honoured. See [Vision and the avatar](/python-sdk/vision#fullscreen-or-beside-your-face) for what each mode is for, and [Sending a picture](/python-sdk/sending-pictures) for putting a picture into chat instead. A Voice Agent hears but does not see, so `look` needs a vision model of your choosing. Set `STANDIN_VISION_API_URL` and `STANDIN_VISION_MODEL` to any OpenAI-compatible endpoint that accepts images, including one you run yourself. The frame is sent for inference and **not stored**, and only the description comes back. Without it the agent is told plainly that looking is unavailable, rather than being left with silence. The whole surface is on [Vision and the avatar](/python-sdk/vision). Pass `describer=` to point this plugin at a different vision model from the one the environment names, which is how one worker answers two identities with two vision budgets. Build it with `FrameDescriber.from_env()` or its constructor, both on [Vision and the avatar](/python-sdk/vision#giving-a-voice-model-eyes). ## How context reaches the agent The Voice Agent API has no non-interrupting context message, so this plugin folds participant counts, key presses and recording changes into the agent's prompt and pushes it with an update. That has a cost worth knowing: **the prompt is resent in full on every change**, so the context section is a rolling window of the last eight notes rather than a transcript. A meeting where people join and leave repeatedly keeps the recent notes and drops the old ones, which is the right trade when the alternative is a prompt that grows for the length of the call. Context that arrives before the agent socket is open is held rather than dropped, then folded in once it exists. The "there are N people here, stay quiet" line and the recording change both land in exactly that gap. ## Tools of your own A custom tool runs in **your** worker, so it can reach whatever your worker can reach: ```python theme={null} from standin import CallServer from standin.plugins.deepgram import CustomTool, DeepgramConfig, DeepgramHandler async def open_ticket(params, ctx): # ctx.call is the live call; ctx.recording says whether it is being recorded. return f"opened ticket for {params['summary']}" tools = [ CustomTool( name="open_ticket", description="Open a support ticket for the caller.", handler=open_ticket, parameters={ "type": "object", "properties": {"summary": {"type": "string"}}, "required": ["summary"], }, ) ] config = DeepgramConfig.from_env() server = CallServer(handler_factory=lambda: DeepgramHandler(config, tools=tools)) await server.start() ``` The `description` is what the model reads to decide whether to call it, so write it for a model rather than for a developer. Keep the handler fast: the caller is waiting in silence while it runs. A custom tool may not shadow a built-in one. That is refused when the handler is built rather than at the first call, because a shadowed `end_call` is an agent that has quietly lost the ability to hang up. ## Inside your own worker The plugin is a `CallHandler` like any other, so you can build it yourself instead of running the module: ```python theme={null} from standin import CallServer from standin.plugins.deepgram import DeepgramConfig, DeepgramHandler config = DeepgramConfig.from_env() server = CallServer(handler_factory=lambda: DeepgramHandler(config)) await server.start() ``` Read the configuration **once**, then close over it. `handler_factory` runs once per call, so `handler_factory=DeepgramHandler` would call `DeepgramConfig.from_env()` again on every call, and a key removed from the environment after startup would fail the next caller rather than failing you. `server.start()` returns as soon as the listener is bound, so a script that ends there exits before a single call arrives. `serve()`, which is what `python -m standin.plugins.deepgram` runs, is the sample above plus an `await asyncio.Event().wait()` to hold the process open and an `await server.aclose()` in a `finally`, which drains live calls and releases the port on the way out. ## Configuration | Variable | What it is | | ------------------ | -------------------------------------------------- | | `DEEPGRAM_API_KEY` | Your API key. Never logged, never sent to StandIn. | | Variable | What it is | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `DEEPGRAM_LISTEN_MODEL` | Speech to text. Defaults to `nova-3`. | | `DEEPGRAM_SPEAK_MODEL` | Text to speech. Defaults to `aura-2-thalia-en`. | | `DEEPGRAM_THINK_PROVIDER` | Which vendor reasons. Defaults to `open_ai`. | | `DEEPGRAM_THINK_MODEL` | Defaults to `gpt-4o-mini`. | | `DEEPGRAM_THINK_ENDPOINT_URL` | Reason with your own model instead. | | `DEEPGRAM_THINK_ENDPOINT_HEADERS` | A JSON object of headers for that endpoint. Carries your model credentials, so it is never logged. Anything but a JSON object is refused when the configuration is read. | | `DEEPGRAM_INSTRUCTIONS` | The agent's base prompt. | | `DEEPGRAM_GREETING` | What the agent says first. | | `DEEPGRAM_LANGUAGE` | Defaults to `en`. | | `DEEPGRAM_AGENT_HOST` | The Voice Agent socket host. Defaults to `agent.deepgram.com`. Must be a deepgram.com host. | | `DEEPGRAM_API_HOST` | The host the REST calls go to. Defaults to `api.deepgram.com`. Must be a deepgram.com host. | | `DEEPGRAM_LOG_TRANSCRIPTS` | Exactly `true` to log turns. Gated a second time on the call being recorded. | The configuration is read once when the worker starts, not per call, so a missing key stops the worker at startup rather than surprising the first caller. Both hosts are checked against the deepgram.com suffix, because your API key travels to them: a wrong host would be credential leakage rather than a failed call. The reasoning behind that shape is on [Configuration](/python-sdk/configuration#why-a-vendor-host-is-pinned). ## Next The startup buffer, the echo guard and barge-in this plugin sits on. What `look`, `show_image` and `express` reach. # Echo plugin Source: https://docs.komaa.com/python-sdk/plugins/echo The smallest StandIn Python plugin that answers a real Microsoft Teams call, and how to copy it into your own. `standin.plugins.echo` is the smallest thing that answers a real Microsoft Teams call. It sends the caller's voice back. That makes it the right thing to run **before** you suspect your own agent: if the echo answers, your secret, your tunnel and your StandIn identity are all correct, and whatever breaks next is yours. It is also the template. Copy it to start your own plugin. ## Run it ```bash theme={null} pip install standin-sdk STANDIN_SECRET=... python -m standin.plugins.echo ``` It needs no extra and no framework. The base install is all of it. Expose port `9442` at the `/msteams/calling` path, register the public `wss://` URL as your StandIn identity's agent voice URL, then call the identity and talk. You should hear yourself. The full walkthrough is in the [Quickstart](/python-sdk/quickstart). ## The whole plugin It is 83 lines across two files: a 75-line `__init__.py` and an eight-line `__main__.py`, which is what makes `python -m standin.plugins.echo` work. Here is that first file, with the licence header, the module docstring, its `__all__` and the closing `if __name__ == "__main__": main()` trailer elided. Copy the file itself rather than this block if you want `python -m` to work on your copy. ```python theme={null} from __future__ import annotations import asyncio from standin import CallServer, CallSession class EchoHandler: """One instance per call. Sends the caller's own voice back to them. A handler implements only what it needs: the SDK treats every callback as optional, so this class defines three of the five and the other two are no-ops. Nothing inherits from anything. """ def __init__(self) -> None: self._call: CallSession | None = None async def on_start(self, session: CallSession) -> None: self._call = session print(f"call {session.call_id} from {session.start.caller.display_name or 'unknown'}") async def on_caller_audio(self, pcm: bytes) -> None: # Your agent goes here. PCM16, 16 kHz, mono, little-endian - the same # format send_audio expects back. if self._call is not None: await self._call.send_audio(pcm) async def on_goodbye(self, text: str) -> None: # StandIn is ending the call and wants this line spoken first. A real # plugin would interrupt the agent and say it. print(f"goodbye: {text}") async def serve() -> None: """Answer Microsoft Teams calls until interrupted.""" server = CallServer(handler_factory=EchoHandler) await server.start() try: await asyncio.Event().wait() # run until cancelled finally: await server.aclose() def main() -> None: try: asyncio.run(serve()) except KeyboardInterrupt: pass ``` Three of the five `CallHandler` methods are defined. `on_context` and `aclose` are absent and that is fine: a missing method is a no-op, because `CallHandler` is a `typing.Protocol` rather than a base class. `on_speaker_change` and `on_video_frame` are separate optional protocols rather than members of that five, so a handler that wants either just defines it. See [The optional callbacks](/python-sdk/call-handler#the-optional-callbacks). Replace `on_caller_audio` with your framework's agent loop and you have a real plugin. Everything else stays. ## What is doing the work Almost nothing in this file is about Microsoft Teams, and that is the point. `CallServer` owns the socket StandIn dials, the HMAC handshake and its replay guard, capacity and draining, the wire protocol, outbound sequence numbers and the audio timeline, both watchdogs and teardown. The plugin owns what to do with a voice. The `asyncio.Event().wait()` is there because `server.start()` returns as soon as the listener is bound. Something has to keep the process alive, and `server.aclose()` in the `finally` is what drains live calls and releases the port on the way out. ## Write your own handler You do not need the SDK's source, a fork or a registration to write a handler. Copy the class out of the block above into your own module, change `on_caller_audio`, and pass it as `handler_factory`: ```python theme={null} from standin import CallServer server = CallServer(handler_factory=MyHandler) await server.start() ``` There is nothing to declare anywhere. `CallHandler` is a `typing.Protocol`, so your class is a handler because it has the right method names, not because it inherits or registers. That is the whole seam, and it is the same one every plugin on this site uses. See [Call handler](/python-sdk/call-handler). ## Contribute a plugin to the SDK This part is different: it is for adding a plugin to the `standin-sdk` package itself, so it assumes a clone of the repository rather than a `pip install`. If you are building for your own deployment, the section above is the one you want. Every plugin lives inside the one package, so adding one publishes nothing new. ```bash theme={null} cp -r libraries/python/standin/plugins/echo libraries/python/standin/plugins/ ``` Your module is now `standin.plugins.`. Add `""` to the `_PLUGINS` tuple in `libraries/python/standin/__init__.py`. That one line is what makes `standin.` resolve, and it is the only registration there is. Nothing is imported at load time, so `import standin` still works on a base install. One extra per plugin in `libraries/python/pyproject.toml`, so the install line reads `pip install "standin-sdk[]"`. Heavy framework dependencies belong there and never in the base: the base install stays `aiohttp` and nothing else. Keep the shape, change `on_caller_audio`. Add `on_context`, `on_goodbye` and `aclose` when your framework has something to do with them. ```bash theme={null} make py-check ``` `make ts-check` is its TypeScript counterpart, and `make check` runs both halves plus the protocol and documentation checks. Then open a pull request. `CONTRIBUTING.md` has the workspace commands and the parity requirements for anything that touches the shared SDK. ## What to add first Once the echo answers, three things turn the template into something usable: **Barge-in.** `await session.cancel_playback()` the moment your provider reports the caller started speaking. Without it the bot keeps talking over the caller for the length of the buffered audio. See [Barge-in](/python-sdk/call-handler#barge-in-and-cancel-playback). **Frame alignment.** If your provider speaks 24 kHz, resample and align before sending. See [Audio](/python-sdk/audio). **Cleanup.** Add `aclose(reason)` to close your provider socket. It is always called exactly once, on every path, before the slot is freed. ## Parity This plugin has a sibling: the TypeScript SDK ships `@komaa/standin-sdk/echo` with the same shape and the same method names in camelCase. Keep them mirrored when you change the seam. ## Next All five methods, with a full worked handler. Every option you may want to change once calls are real. # ElevenLabs plugin Source: https://docs.komaa.com/python-sdk/plugins/elevenlabs Answer Microsoft Teams calls with an ElevenLabs agent, using the StandIn Python SDK. `standin.plugins.elevenlabs` puts [ElevenLabs](https://elevenlabs.io/docs/agents-platform/overview) on a real Microsoft Teams call. StandIn answers the call and dials your worker; this plugin answers that dial, opens one session per call, and relays the audio both ways. ## Install and run There is nothing to install beyond the SDK. ElevenLabs is reached over an ordinary WebSocket, so this plugin adds no dependency and needs no extra. ```bash theme={null} pip install standin-sdk ``` ```bash theme={null} export STANDIN_SECRET=... # your StandIn connection secret export ELEVENLABS_API_KEY=... export ELEVENLABS_AGENT_ID=... python -m standin.plugins.elevenlabs ``` Expose port `9442` at the `/msteams/calling` path and register the public `wss://` URL as your StandIn identity's agent voice URL. The full walkthrough is in the [Quickstart](/python-sdk/quickstart), and there is a runnable example at [examples/elevenlabs-msteams-connector](https://github.com/komaa-com/standin/tree/main/examples/elevenlabs-msteams-connector). ## The audio format In the ElevenLabs dashboard, set the agent's input **and** output audio format to `pcm_16000`. That is exactly what a Microsoft Teams call carries, so nothing resamples anything and the latency you measure is the model's rather than the transport's. An agent set to anything else is refused at the first frame, with one clear log line, rather than producing a whole call of garbled audio. ## What the agent can do about the call Declare these as **client tools** on the agent. Nothing to implement: the plugin answers them. `client_tools()` prints the declarations, so there is nothing to retype into the dashboard: ```bash theme={null} python -c "import json, standin.plugins.elevenlabs as e; print(json.dumps(e.client_tools(), indent=2))" ``` | Tool | Parameters | What it does | | ------------ | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `end_call` | none | Hang up. | | `express` | `emotion` | Set the avatar's expression on the bot's tile. See [The avatar](/python-sdk/avatar#the-emotion-the-face-wears). | | `show_image` | `url` **or** `dataBase64` + `mime`, plus `caption?` and `durationMs?` | Put a picture on the bot's video tile. | | `look` | `source?`, `question?` | Look at the caller's screen share or camera. | | `look_back` | `question?` | Look again at something the caller has already moved past. Recorded calls only. | `show_image` is widened here, because ElevenLabs is the one provider that can hand you the bytes inline: either form will do, so neither argument is required. A URL is chosen by the model, which the caller steers, so it is fetched through the SDK's guard: public hosts only, no private or link-local addresses, and the address is re-checked at connect time to close the DNS rebind window. **The fullscreen or overlay choice is not selectable on this plugin.** The other provider plugins route `show_image` through `CallTools.dispatch`, which honours a `display` parameter. This one has its own dispatch, and it reads `mode`. `client_tools()` still emits `display` with its `fullscreen` / `overlay` enum, because that comes from the shared tool spec, so the declaration you paste will offer the model a choice that is then dropped in silence. Deleting the `display` property from the pasted declaration changes nothing except that the model stops being offered it, which is the honest version. Either way the picture goes up with the default placement. [Vision and the avatar](/python-sdk/vision#fullscreen-or-beside-your-face) covers what the two placements are for. `look` and `look_back` upload the frame into the ElevenLabs conversation, which **stores** the caller's screen with a third party. They therefore work only while the Microsoft Teams call is being recorded. When it is not, the agent is told why rather than being left with silence. ## Inside your own worker The plugin is a `CallHandler` like any other, so you can build it yourself instead of running the module: ```python theme={null} from standin import CallServer from standin.plugins.elevenlabs import ElevenLabsConfig, ElevenLabsHandler config = ElevenLabsConfig.from_env() server = CallServer(handler_factory=lambda: ElevenLabsHandler(config)) await server.start() ``` Read the configuration **once**, then close over it. `handler_factory` runs once per call, so `handler_factory=ElevenLabsHandler` would call `ElevenLabsConfig.from_env()` again on every call, and a key removed from the environment after startup would fail the next caller rather than failing you. `server.start()` returns as soon as the listener is bound, so a script that ends there exits before a single call arrives. `serve()`, which is what `python -m standin.plugins.elevenlabs` runs, is the sample above plus an `await asyncio.Event().wait()` to hold the process open and an `await server.aclose()` in a `finally`, which drains live calls and releases the port on the way out. ## Configuration | Variable | What it is | | --------------------- | -------------------------------------------------- | | `ELEVENLABS_API_KEY` | Your API key. Never logged, never sent to StandIn. | | `ELEVENLABS_AGENT_ID` | Which agent answers the call. | | Variable | What it is | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ELEVENLABS_FIRST_MESSAGE` | Override the opening line. Applied only when the agent's security settings allow the override. | | `ELEVENLABS_ENVIRONMENT` | For an agent deployed to a named environment. | | `ELEVENLABS_AGENT_BRANCH_ID` | Answer with a specific branch. | | `ELEVENLABS_HOST` | Defaults to `api.elevenlabs.io`. Must be an elevenlabs.io host. | | `ELEVENLABS_LOG_TRANSCRIPTS` | Exactly `true` to log turns. Gated a second time on the call being recorded: a transcript in your logs is a recording of the caller that they did not agree to. | The configuration is read once when the worker starts, not per call, so a missing key stops the worker at startup rather than surprising the first caller. `ELEVENLABS_HOST` is checked against the elevenlabs.io suffix, because your API key travels to it: a wrong host would be credential leakage rather than a failed call. The reasoning behind that shape is on [Configuration](/python-sdk/configuration#why-a-vendor-host-is-pinned). ## Next The startup buffer, the echo guard and barge-in this plugin sits on. What `look`, `show_image` and `express` reach. # Hermes Agent Source: https://docs.komaa.com/python-sdk/plugins/hermes-agent Answer Microsoft Teams calls with your Hermes Agent using standin-sdk[hermes-agent]: realtime speech-to-speech, in-process loading, and the tool boundary. `standin-sdk[hermes-agent]` answers Microsoft Teams calls with your [Hermes Agent](https://github.com/NousResearch/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](#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. ```mermaid theme={null} flowchart TD T["Microsoft Teams call"] --> S["StandIn service
joins the call, owns the Microsoft side"] S -->|"one HMAC-authenticated WebSocket per call"| P["standin-sdk[hermes-agent]
answers the dial, runs the realtime session"] P --> R["Realtime model
the conversation: hears, answers, interrupts"] P --> H["Hermes agent
the work: lookups, files, web, your skills"] ``` 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: ```bash theme={null} pip install "standin-sdk[hermes-agent]" ``` 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. ```toml theme={null} [project.entry-points."hermes_agent.plugins"] msteams_bridge = "standin.plugins.hermes_agent" ``` The entry point key is `msteams_bridge`, the name shown in `hermes plugins list` and used in `plugins.enabled`. 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 ```yaml theme={null} # /config.yaml plugins: enabled: [msteams_bridge] entries: msteams_bridge: config: # Deny by default. Put the AAD object ids that may call. allowlist: ["00000000-0000-0000-0000-000000000000"] session_scope: per-aad realtime: voice: alloy ``` ```bash theme={null} export STANDIN_SECRET=... # from the StandIn portal export OPENAI_API_KEY=... # the realtime model hermes msteams-bridge serve ``` 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](/expose) 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 ```bash theme={null} hermes msteams-bridge status ``` 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. ```bash theme={null} hermes msteams-bridge smoke ``` 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](/python-sdk/checking-the-install). There are exactly three subcommands, `status`, `smoke` and `serve`, and the set is deliberate. ### Running without Hermes ```bash theme={null} STANDIN_SECRET=... OPENAI_API_KEY=... python -m standin.plugins.hermes_agent ``` 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. Copy each allowlisted id from `session.start.caller.aad_id` on an inbound call. Emails never match. | Key | Environment | Default | Meaning | | ----------------------- | -------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `allowlist` | `MSTEAMS_BRIDGE_ALLOWLIST` | *(empty)* | AAD object ids that may call. **Empty denies everyone** unless `allow_all`. | | `allow_all` | `MSTEAMS_BRIDGE_ALLOW_ALL` | `false` | Accept any caller. Explicit opt-in. | | `allowlist_allow_names` | `MSTEAMS_BRIDGE_ALLOWLIST_ALLOW_NAMES` | `false` | Match display names too. Spoofable, so off by default. | | `require_recording` | `MSTEAMS_BRIDGE_REQUIRE_RECORDING` | `true` | Wait for the Microsoft Teams recording banner before speaking or listening. | | `meeting_recap` | `MSTEAMS_BRIDGE_MEETING_RECAP` | `false` | After the call ends, post minutes to the Microsoft Teams chat. Needs the StandIn chat lane and a summarization consult. Best-effort: hang-up does not await the post. Transcript is written to disk first. | | `session_scope` | `MSTEAMS_BRIDGE_SESSION_SCOPE` | `per-call` | Memory of the agent session used for consults and the minutes: `per-call`, `per-thread`, `per-aad`. | | `wake_phrases` | `MSTEAMS_BRIDGE_WAKE_PHRASES` | `assistant, hermes` | What addresses the assistant in a meeting. | | `require_address` | `MSTEAMS_BRIDGE_REQUIRE_ADDRESS` | `true` | Stay silent in a meeting until addressed. | | `follow_up_window_ms` | `MSTEAMS_BRIDGE_FOLLOW_UP_WINDOW_MS` | `12000` | How long an addressed turn keeps the floor. | | `consult_timeout_s` | `MSTEAMS_BRIDGE_CONSULT_TIMEOUT_S` | `45` | How long one agent consult may take. | | `consult_model` | `MSTEAMS_BRIDGE_CONSULT_MODEL` | *(the host's `model:`)* | Override the consult's model. | The `realtime:` sub-block configures the provider: | Key | Environment | Default | | ------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------- | | `backend` | `MSTEAMS_BRIDGE_REALTIME_BACKEND` | OpenAI; set `azure` for Azure OpenAI | | `api_key` | `MSTEAMS_BRIDGE_REALTIME_API_KEY` | `OPENAI_API_KEY`, or `AZURE_OPENAI_API_KEY` / `AZURE_FOUNDRY_API_KEY` | | `model` | `MSTEAMS_BRIDGE_REALTIME_MODEL` | `gpt-realtime` | | `voice` | `MSTEAMS_BRIDGE_REALTIME_VOICE` | `alloy` | | `azure_endpoint` / `azure_deployment` | `MSTEAMS_BRIDGE_AZURE_ENDPOINT` / `_DEPLOYMENT` | none | | `azure_api_version` | `MSTEAMS_BRIDGE_AZURE_API_VERSION` | `2024-10-01-preview`, on the Azure path only | | `url` | `MSTEAMS_BRIDGE_REALTIME_URL` | derived from the backend; set it to dial a socket of your own | | `instructions` | `MSTEAMS_BRIDGE_REALTIME_INSTRUCTIONS` | a short voice-behaviour prompt that says nothing about identity | | `languages` | `MSTEAMS_BRIDGE_LANGUAGES` | detect and mirror the caller | | `input_transcribe_model` | `MSTEAMS_BRIDGE_INPUT_TRANSCRIBE_MODEL` | `whisper-1`; `off`, `none` or `disabled` turns it off | **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: | Key | Environment | Default | | --------------------- | ------------------------------------ | ------- | | `vad_threshold` | `MSTEAMS_BRIDGE_VAD_THRESHOLD` | `0.5` | | `prefix_padding_ms` | `MSTEAMS_BRIDGE_PREFIX_PADDING_MS` | `300` | | `silence_duration_ms` | `MSTEAMS_BRIDGE_SILENCE_DURATION_MS` | `500` | 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`). Unfinished recaps sit under `STANDIN_RECAP_DIR`, else `STANDIN_STATE_DIR/recap`, else `~/.standin/state/recap`. 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 | Tool | Does | | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | | `hermes_agent_consult` | Runs the caller's real Hermes agent: lookups, files, web, tools, installed skills. Returns a short spoken result. | | `set_call_language` | Pins the call to one language, applied to the session in flight. | 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](/python-sdk/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](/python-sdk/group-calls#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. A participant count does not arrive on the meeting-join path, so a gate that used only that number would answer 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](#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](/python-sdk/vision) * ringing somebody back: [Reaching people](/python-sdk/reaching-people), where `CALL_BACK_TOOL` and `CHAT_CALLBACK_TOOL` ship from the `standin` barrel * writing up the meeting yourself, if you did not turn `meeting_recap` on: [Meeting recap](/python-sdk/minutes) ## Meeting recap Off until `meeting_recap: true` (or `MSTEAMS_BRIDGE_MEETING_RECAP=true`). The plugin then: 1. Collects the call transcript. 2. Writes a restart-recoverable local spool before hang-up returns. A process restart drains what was left if that directory survives the process. Treat recap as best-effort unless that directory is on storage that survives a process or container restart. The file stays until posted, expired (24 hours), or the retry limit (8). 3. Posts minutes through chat. If Hermes already gave this process a `respond` handler, recap reuses that lane. If not, it opens a **listen-only** [chat lane](/python-sdk/chat) that never answers. The host must provide an outbound chat lane and a summarization consult. Hang-up does not wait for the consult. Meeting recaps are best-effort. They may be lost if the worker exits during processing. This is the difference from the OpenClaw plugin. OpenClaw always opens a dedicated listen-only lane when recap is on. Hermes does not open a second lane when the host already answers chat. Delivery uses StandIn's outbound chat socket. That socket exists on the **StandIn AI Teammate app** ([Tiers](/tiers)). A bring-your-own-bot identity answers chat through its Azure Bot messaging URL, not this socket, so this switch will not post there. Put `STANDIN_RECAP_DIR` on persistent storage when recap reliability matters. A directory that dies with the process loses unfinished minutes. `session_scope` keys the **consult that writes the minutes**, not the voice session. Voice is always one session per call. ## 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](/python-sdk/avatar) is the page for doing the same thing from a handler of your own. ## Full example [`examples/hermes-msteams-connector`](https://github.com/komaa-com/standin/tree/main/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 The seam this plugin implements. The 16 kHz wire against a 24 kHz realtime model. The startup buffer, the echo guard and barge-in this plugin sits on. The expression and viseme hints this plugin sends for you. # LiveKit plugin Source: https://docs.komaa.com/python-sdk/plugins/livekit Answer Microsoft Teams calls with a LiveKit Agent using standin-sdk[livekit]: two lines, CallInfo, and the two data topics. `standin-sdk[livekit]` answers Microsoft Teams calls with a [LiveKit Agent](https://docs.livekit.io/agents/). StandIn answers the Microsoft Teams call and dials your worker. This plugin answers that dial, creates one LiveKit room per call, dispatches your own agent into it, and relays the audio both ways. By the time your entrypoint runs, the call is an ordinary LiveKit room: the caller's voice is a room track like any other participant's, so the session needs no special audio wiring. ## Install In your worker's Python environment: ```bash theme={null} pip install "standin-sdk[livekit]" ``` The extra pulls in `livekit-agents`. The example adds the model and VAD plugins it uses: ```bash theme={null} pip install "standin-sdk[livekit]" "livekit-agents[openai,silero]" ``` Python 3.10 or newer, and `livekit-agents` 1.6.10 or newer. ## The two StandIn-specific lines Out of a whole agent file, two lines are new: ```python theme={null} from standin.plugins import livekit as standin ... call = await standin.TeamsCall().start(session, ctx=ctx) ``` Everything else is the shape every LiveKit agent example already has. ```python theme={null} from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli from livekit.plugins import openai from standin.plugins import livekit as standin class MyAgent(Agent): def __init__(self, call: standin.CallInfo) -> None: super().__init__( instructions=f"You are on a Microsoft Teams call with {call.caller_name}.", ) server = AgentServer() @server.rtc_session(agent_name="standin-msteams") async def entrypoint(ctx: JobContext): session = AgentSession(llm=openai.realtime.RealtimeModel()) call = await standin.TeamsCall().start(session, ctx=ctx) await session.start(agent=MyAgent(call), room=ctx.room) if __name__ == "__main__": cli.run_app(server) ``` **Importing the plugin arms it. Setting `STANDIN_SECRET` starts it.** A worker without that variable behaves exactly as if the plugin were not installed, so the same file can keep serving your web and SIP rooms unchanged. There is no bootstrap call. Importing registers the plugin with the worker, the same import-time registration every LiveKit plugin performs, and the call listener starts on the worker's own `worker_started` event: once, in the main process, with the loop already running. Why a listener at all: StandIn dials the worker per call, and no job exists until that dial has been answered and an agent dispatched. So the listener cannot live inside the entrypoint. It lives inside the worker's lifecycle instead, where you never see it. ## Configuration Environment, unless you construct the handler yourself: see [Advanced](#advanced-constructing-the-handler-yourself). | Variable | Default | Meaning | | ------------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `STANDIN_SECRET` | *(required)* | Connection secret from the StandIn portal. Arms the plugin. | | `STANDIN_PORT` | `9442` | Port the call listener binds. | | `STANDIN_HOST` | `0.0.0.0` | Bind address. Use `127.0.0.1` when only a local tunnel should reach it. | | `STANDIN_WS_PATH` | `/msteams/calling` | Path StandIn dials. | | `LIVEKIT_URL` | *(required)* | Your LiveKit project. The worker already has these three. | | `LIVEKIT_API_KEY` | *(required)* | | | `LIVEKIT_API_SECRET` | *(required)* | Never logged, never sent to StandIn. | | `LIVEKIT_AGENT_NAME` | *(unset)* | Fallback for the name the worker registered with. The registered name wins whenever it can be read; see [Dispatch](#dispatch). | | `LIVEKIT_TILE_VIDEO` | `auto` | Relay your agent's own video onto the bot's tile. See [The avatar tile](#the-avatar-tile). | | `LIVEKIT_TILE_VIDEO_FPS` | `12` | Frames per second for that relay. Clamped to `MAX_TILE_FPS`, which is 20. A value that is not a whole number above zero is refused, naming the variable. | A missing LiveKit variable is caught at **startup**, with one clear log line, rather than on the first real call with a caller already on the line. A misconfigured plugin logs why it is not answering calls and lets the worker run; it never takes the worker down. 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](/expose) 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. ## What you get in the entrypoint `TeamsCall().start(session, ctx=ctx)` returns a `CallInfo` and wires two data topics onto your session. | Topic | Carries | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `msteams.context` | Non-interrupting context: participant counts and group-call etiquette, DTMF digits, recording status. Logged by default; pass `on_context=` to handle it. | | `msteams.goodbye` | The line StandIn wants spoken before it ends the call. The default handler interrupts the current turn and says it, which is what you want: teardown follows within seconds. | Both topics carry `{"text": ...}`, and the plugin's handler is the only publisher. The names are exported as `standin.TOPIC_CONTEXT` and `standin.TOPIC_GOODBYE`. ```python theme={null} def note_context(text: str) -> None: log.info("Microsoft Teams context: %s", text) call = await standin.TeamsCall(on_context=note_context).start(session, ctx=ctx) ``` `on_goodbye=` is the matching hook for the other topic, and it **replaces** the default rather than running beside it: pass one and the interrupt-then-speak behaviour above is yours to reproduce. Leave it unset unless the closing line needs to go somewhere other than the caller's ear. Both callbacks may be plain function or coroutine, and both run as the packet arrives rather than being awaited by anything. A handler that raises is logged and the call carries on, which is the right trade for a topic that is advisory: losing one context line must not end a call. Context is queued until the agent is actually bound to the room, then published. The SDK itself does not queue, and that is right: what "ready" means is a framework's own business. For LiveKit it means something specific, because a data packet reaches only the participants connected at that instant, and the first `participants` message arrives seconds before the dispatched agent joins. The goodbye is not queued. The SDK has already told StandIn to drop the agent's buffered audio and teardown follows in seconds, so a goodbye with no agent to hear it was never going to be spoken. `start()` reads the caller off `ctx` and attaches the topic listener to `ctx.room`. Pass `room=` as well when the room you want the listener on is not `ctx.room`; it overrides the target and nothing else, so `ctx` is still what says who is calling. The record `start()` returned is also kept on the `TeamsCall` itself as `.info`, so a helper handed the object does not need the return value threaded to it. ## CallInfo ```python theme={null} info = standin.CallInfo.from_job(ctx) if info.is_teams_call: call = await standin.TeamsCall().start(session, ctx=ctx) ``` | Field | Meaning | | --------------- | ------------------------------------------------------------------------------ | | `caller_name` | The caller's Microsoft Teams display name, or `"caller"`. | | `tenant_id` | The caller's Microsoft tenant. | | `call_id` | StandIn's id for this call. | | `thread_id` | The Microsoft Teams thread. A meeting or channel conversation begins `19:`. | | `user_id` | The caller's AAD object id. | | `direction` | `inbound` or `outbound`. | | `is_teams_call` | False when this job was dispatched by something other than the StandIn plugin. | `CallInfo.from_job` never raises. A job dispatched by anything else has no metadata, or metadata in someone else's shape, and both read as "not a Microsoft Teams call" rather than taking the worker down. It looks in three places in order, the job's own metadata, the job's room snapshot and `ctx.room`, so it finds the record on the automatic-dispatch path too, including before `ctx.room` has connected. `TeamsCall().start()` is the half that **does** raise. Handed a job this plugin did not dispatch it raises `StandInError` rather than attaching to a call that is not there, and it raises again if neither `ctx=` nor `room=` gives it a room. That is why the `is_teams_call` check above is a guard and not a nicety: in a worker that also serves web or SIP rooms, calling `start()` unguarded turns every non-Teams job into a failed entrypoint. `user_id` is **empty for guest and anonymous callers**, in both SDKs. Never use it as a bare key for per-caller memory without checking it first, or two anonymous callers share one identity. **The two SDKs fill an unknown field differently, and an agent that reads the raw metadata will see it.** This plugin always writes `caller_name`, `tenant_id` and `thread_id`, substituting `"caller"` and `"unknown-tenant"` when StandIn does not know them, which is what the table above describes. The TypeScript plugin omits an empty field instead, so the same key is simply absent there. Reading through `CallInfo` hides the difference; reading `job.metadata` yourself does not. ## Dispatch With `agent_name=` set in `@server.rtc_session(...)`, the plugin dispatches explicitly, which is the recommended setup. Without it, the plugin relies on automatic dispatch, where creating the room is itself what assigns the job. It logs which mode it is in at startup. The name is read off the worker rather than configured twice, and it is resolved at `worker_started` rather than earlier, so the framework's own precedence applies and the plugin cannot disagree with the worker about who it is. Two copies of a string that must match is how you get a room that is created, a job that never arrives, and a caller who hears silence. Either way the plugin attaches the same call metadata to the room as it is created, so `CallInfo` reads it even on the automatic path, where jobs carry no dispatch metadata. ## Rooms Room names are `{prefix}{callId}`, defaulting to `msteams-`, with the `callId` reduced to a conservative charset and the whole name capped at 100 characters. That derivation is a contract rather than a detail, so a room created for a given call is the same room whichever shipped plugin created it, and both SDKs compute it identically. The prefix itself is configurable, and this is one of the few places the two SDKs differ in how: here it is the `room_prefix` argument on `TeamsCallHandler`, and in the TypeScript plugin it is the `LIVEKIT_ROOM_PREFIX` environment variable. Change it in one worker and the room a call lands in is no longer the room the other worker would have made, which is the point of writing the contract down. At teardown the plugin disconnects and deletes the room, so the agent job ends at once instead of idling out: a job whose room still exists sits there until LiveKit's own empty-room timeout, which is minutes of a worker slot doing nothing. If the delete fails it logs and lets the room idle out rather than failing the call. Turning it off is `delete_room_on_end=False` here, and `LIVEKIT_DELETE_ROOM=off` in the TypeScript plugin. ## Draining and shutdown The plugin hooks the LiveKit worker's own drain: when the worker starts draining, the call listener stops accepting new dials while live calls continue. A draining worker must not answer a dial it will never dispatch, and the default LiveKit drain window is an hour. On shutdown it closes the listener before the worker finishes, cancelling a slow start first so a shutdown racing a start cannot bind the port after cleanup. ## The avatar tile If your agent publishes video of its own, an avatar worker or a rendered face, it is relayed onto the bot's video tile automatically and **on by default**. The caller sees your agent rather than StandIn's own avatar. When it does not, `express` and `send_speech_marks` still drive StandIn's avatar, which [The avatar](/python-sdk/avatar) covers. `LIVEKIT_TILE_VIDEO` takes three kinds of value, and the third is the one worth knowing about: | Value | What happens | | --------------- | ---------------------------------------------------- | | unset or `auto` | Relay whichever participant's video the room offers. | | `off` | No relay. The caller sees StandIn's avatar. | | anything else | A participant **identity** to pin the relay to. | Pin an identity when a separate worker publishes the avatar and LiveKit's publish-on-behalf attribute is not set: without a name the relay takes whichever participant published first, which on a busy room is the wrong one. With the attribute set there is nothing to pin, because that is the first thing the plugin looks at, falling back to the agent's own identity. Tracks are matched by kind, never by source. An avatar worker publishes untagged video, so a source filter would pick the right participant and then stream nothing, which is the most expensive way to get this wrong. Frames are paced, latest-wins, and dropped rather than queued when the socket is congested, because both streams share one socket and a caller forgives a dropped frame far more readily than a break in the voice. `LIVEKIT_TILE_VIDEO_FPS` defaults to 12 and is clamped to `MAX_TILE_FPS`, which is 20: a talking head gains nothing above that, and a higher rate only spends local CPU on encoding. A value that is not a whole number above zero is refused, naming the variable: an operator who typed `twelve` is looking at a setting that is not the one in force, and silence is what makes that take an afternoon to find. Encoding needs the `tile` extra: `pip install "standin-sdk[tile]"`. Without it the relay stays off with one line in the log and **the call is unaffected**: the caller hears your agent and sees StandIn's rendered avatar, which is what they would have seen anyway. ## Advanced: constructing the handler yourself `TeamsCallHandler` is exported for embedding without an `AgentServer`. It takes `agent_name`, the three `livekit_*` credentials, `room_prefix`, `delete_room_on_end`, `tile_video` and `tile_video_fps`. The last two are the constructor forms of the tile variables above, so a worker built this way sets them in code rather than in the environment. Normal workers never construct it: the plugin's own factory does. ## Full example [`examples/livekit-msteams-connector`](https://github.com/komaa-com/standin/tree/main/examples/livekit-msteams-connector) is a complete, runnable worker with the `.env` layout, the tunnel command and the agent file. ## Next What the plugin implements on your behalf. The template for a framework the SDK has no plugin for. The rest of the avatar surface: expression, visemes and your own frames on the tile. # Quickstart Source: https://docs.komaa.com/python-sdk/quickstart Install the StandIn Python SDK, answer a real Microsoft Teams call, and hear your own voice back. The echo plugin sends your voice back. Run it first, before you involve an agent: if the echo answers, your secret, your tunnel and your StandIn identity are all correct, and anything that breaks afterwards is your agent. ## Prerequisites * Python 3.10 or newer * A StandIn identity and its connection secret, from [standin.komaa.com](https://standin.komaa.com) * A way to expose one local port over HTTPS and WebSocket: a tunnel such as Tailscale Funnel or ngrok, or an ingress in front of a container ## Step 1: install ```bash theme={null} python3 -m venv .venv source .venv/bin/activate pip install standin-sdk ``` That is the whole install. The echo plugin used below is part of the package, so there is nothing else to add until you pick a framework. ## Step 2: set the secret `STANDIN_SECRET` is the only required variable. It is what the handshake signature is checked against, and it is what arms the listener at all: without it, `CallServer` refuses to construct. ```bash theme={null} export STANDIN_SECRET="your-StandIn-connection-secret" export STANDIN_HOST="127.0.0.1" ``` `STANDIN_HOST=127.0.0.1` keeps the listener off every other interface while a local tunnel is the only thing reaching it. The default is `0.0.0.0`, which is what you want in a container behind an ingress. The upgrade is HMAC-authenticated either way. | Variable | Default | Meaning | | --------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `STANDIN_SECRET` | *(required)* | Connection secret from the StandIn portal. Arms the listener. | | `STANDIN_PORT` | `9442` | Port the call listener binds. | | `STANDIN_HOST` | `0.0.0.0` | Bind address. | | `STANDIN_WS_PATH` | `/msteams/calling` | Path StandIn dials. | | `STANDIN_CHAT_SECRET` | `STANDIN_SECRET` | The key the chat lane signs with. A StandIn AI Teammate app connection may issue a separate key for chat, and this one wins when it is set. See [Chat](/python-sdk/chat#which-secret). | | `STANDIN_CHAT_URL` | `wss://teams.standin.komaa.com/api/chat/channel` | Chat channel the worker dials out to. Only used by [ChatChannel](/python-sdk/chat). | Those six are all the call and chat lanes read. The other lanes have their own, and [Configuration](/python-sdk/configuration) is the one page that lists every `STANDIN_` variable with what owns it. Configuration is environment-only by design, so credentials never reach a source file and the plugins all read their keys the same way. ## Step 3: run the echo plugin ```bash theme={null} python -m standin.plugins.echo ``` You should see one line naming the address, the port and the path it is answering on. The process now waits for a call and does nothing else. ## Step 4: expose the port StandIn dials your worker, so the worker needs a public `wss://` address with the `/msteams/calling` path routed to port `9442`. [Expose your agent](/expose#mount-the-call-path) has the mount command for Tailscale Funnel, ngrok, cloudflared and devtunnel, and the probes that tell a registered mount from a working one. It is the only page that carries those commands, because three divergent spellings of one URL across three pages is what caused a live incident here. Terminate TLS at your public ingress. The path has to match, because that is where the per-call `callId` segment is appended: StandIn dials `wss:///msteams/calling/{callId}`. Expose only that path, and keep the secret out of version control. Anyone who can reach the port still needs a valid signature within the freshness window, but there is no reason to publish more surface than one route. ## Step 5: register the URL and call In the StandIn portal, set your identity's agent voice URL to the public `wss://` URL, including the `/msteams/calling` path. Then call the identity from Microsoft Teams and talk. You should hear yourself, delayed by the round trip. The worker logs the call id and the caller's display name when the call starts, and the close reason when it ends. ## What just happened ```mermaid theme={null} sequenceDiagram participant T as Microsoft Teams participant SI as StandIn service participant W as Your worker T->>SI: caller dials the identity SI->>W: WSS upgrade to /msteams/calling/{callId}, signed W-->>SI: 101, handshake verified once SI->>W: session.start W->>W: on_start(session) SI->>W: audio.frame (PCM16 16 kHz, continuously) W->>SI: audio.frame (your reply) SI->>W: session.end W->>W: aclose(reason) ``` ## If it does not answer | Symptom | Usual cause | | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401 unauthorized` in your ingress logs | The secret does not byte-match the portal value, or the clock is more than 60 seconds out. | | `401 handshake already used` | A correctly signed upgrade was replayed. The single-use guard is doing its job. | | `409 call already has a live session` | A previous call for that `callId` has not finished tearing down, or a retry arrived while the first socket was still open. | | `503 at capacity` or `503 draining` | `max_connections` is reached, or the worker is winding down. | | The call connects and goes silent | The socket authenticated but `session.start` never arrived, and the pre-start watchdog dropped it after ten seconds. Check that the path StandIn dials matches `STANDIN_WS_PATH`. | | The call connects, stays up, and ends at two minutes with `no-agent-answered` | Nothing ever answered it. The [stale-call reaper](/python-sdk/call-server#the-stale-call-reaper) is doing its job: an agent was never dispatched, or a plugin that joins a room never called `session.mark_answered()`. | `GET /healthz` on the same port returns `{"ok": true, "calls": }`, which is the cheapest way to confirm the listener is really bound. ## Next Replace the echo with your own agent loop. The template, and how to turn it into your own plugin. Two StandIn-specific lines in an ordinary LiveKit agent file. Your Hermes agent, answering the phone in-process. # Reaching people Source: https://docs.komaa.com/python-sdk/reaching-people Speak into a call that is already up, otherwise ring somebody back and park the line until they answer, in the StandIn Python SDK. Every other lane in this SDK starts with somebody dialling your agent. This one runs the other way: something outside a call wants to reach a person. A scheduled job finished, a chat message asked to be called, a host handed off a task. Which way that lands depends on one thing: whether a call to that person is up right now. If one is, the line is spoken into it. Ringing somebody who is mid sentence with you is the rudest possible way to tell them something. If one is not, a call has to be placed and the line parked, so it is said the moment they answer. `VoiceDelivery` makes that choice, and `LiveCalls` is what it consults. ```python theme={null} from standin import LiveCalls, OutboundCaller, OutboundPolicy, PendingMessages, VoiceDelivery live = LiveCalls() delivery = VoiceDelivery( live, caller=OutboundCaller(), policy=OutboundPolicy.from_env(), pending=PendingMessages(), ) result = await delivery.deliver("Your build finished.", target=user_object_id) ``` Four pieces, and each answers one question. `live` is which calls are up. `policy` is whether this agent may ring that person at all. `caller` places the call. `pending` is what to say when they pick up. `OutboundCaller()` reads `STANDIN_SECRET` and refuses to be built without it, because that secret is what signs the request. `STANDIN_WORKER_URL` is the StandIn control address it posts to, which StandIn gives you with your connection secret. Outbound calling is unavailable until it is set, and a URL that is not http or https, has no host, or carries credentials is refused when the caller is built rather than when somebody tries to ring. Every argument after `live` is optional, and the defaults are the safe ones rather than the convenient ones. With no `policy`, `VoiceDelivery` builds `OutboundPolicy()`, whose allowlist is empty, so every call-back is refused. Pass `OutboundPolicy.from_env()` or an explicit list. ## Registering a live call A call registers itself, from the handler that owns it, for the life of that call. ```python theme={null} from standin import CallServer, CallSession, LiveCalls, decode_wav class MyHandler: def __init__(self, live: LiveCalls) -> None: self._live = live self._session: CallSession | None = None async def on_start(self, session: CallSession) -> None: self._session = session self._live.register(self, call_id=session.call_id, thread_id=session.start.thread_id) async def say(self, text: str) -> None: # Whatever makes this plugin's provider speak. await self._session.send_audio(decode_wav(await tts.speak(text))) async def aclose(self, reason: str) -> None: session, self._session = self._session, None if session is not None: self._live.unregister( self, call_id=session.call_id, thread_id=session.start.thread_id ) live = LiveCalls() server = CallServer(handler_factory=lambda: MyHandler(live)) ``` `say(text)` is the whole `LiveSpeaker` protocol. It belongs to the plugin because only the plugin knows how to make its provider speak without stepping on whatever the agent was already saying. ### Two keys, one call `register(speaker, call_id, thread_id="")` files the speaker under the call id, and under the Microsoft Teams conversation when the call has one. Both, because a delivery addressed by conversation would otherwise miss a call that is only filed by call id, and the agent would place a second call to somebody already on the line with it. Each key is trimmed, and an empty one is skipped. `len(live)` counts keys rather than calls, so a call filed under both counts twice. `deliver` looks the conversation up first and the target second. Whichever key names a live call wins, and `find(*keys)` returns `None` when none of them does. ### Unregister removes this speaker's keys, and only this speaker's `unregister(speaker, call_id, thread_id="")` deletes a key only when it still points at the speaker that is being removed. That identity check is the point. A second call on the same conversation can start before the first one's teardown runs. A blind delete would then wipe the live call's entry, and every later delivery for that conversation would ring a fresh call. The person would hear a second ring instead of an answer. ### When the live call cannot speak If `say` raises, the delivery does not stop there. It logs, falls through to the call-back path, and comes back with `mode="call-back"`. A wedged provider socket would otherwise swallow the message with nobody told. The trade is deliberate: a half-spoken line can be repeated by the call-back, and a repeat beats silence. ## Who the agent may ring `OutboundPolicy` is allow-by-explicit-listing. A directory id must be on the list before the agent can ring it, and an empty list means outbound calling is off. | Field | Default | What it is | | -------------- | ------- | ----------------------------------------------------------------------------------------------------------------- | | `allowed` | empty | Directory ids the agent may ring. Empty means outbound is off. | | `max_per_hour` | `6` | Calls placed in any rolling hour, across all targets. Zero means no cap, which is a choice rather than a default. | `OutboundPolicy.from_env()` reads `STANDIN_OUTBOUND_ALLOW`, a comma-separated list of directory ids, and `STANDIN_OUTBOUND_MAX_PER_HOUR`. Unset means off. Matching is case-folded on both sides. A directory id is not case-sensitive, and a case mismatch would read as "not allowed" with nothing to say why. This list is separate from any inbound allowlist, and stricter. Allowing every inbound caller allows no outbound target. The two answer different questions. Inbound asks "may this person talk to the agent?" and the person chose to dial. Outbound asks "may the agent ring this person?" and the agent was talked into it by whoever is on the call. An agent with an outbound tool and no allowlist is an agent that can be talked into cold-calling your directory. Every refusal is one sentence, safe to read out loud: | When | What comes back in `error` | | ---------------- | ---------------------------------------------------------------------------------------------- | | No allowlist | `outbound calling is off: set STANDIN_OUTBOUND_ALLOW to the directory ids this agent may ring` | | Not on it | `that person is not on this agent's outbound allowlist` | | No target at all | `an outbound call needs the person's directory id` | | Over the cap | `this agent has already placed 6 calls in the last hour` | ## The tenant is operator configuration Which organisation a call is placed into comes from the `tenant_id` argument to `VoiceDelivery`, falling back to `STANDIN_TENANT_ID`. That variable name is exported as `TENANT_ENV`. It never comes from the message, the metadata, the model or the caller. A model is steered by whoever is talking to it, so a model that can choose the tenant is a model that can be talked into dialling a different organisation. With no tenant configured, `deliver` returns `no tenant is configured: set STANDIN_TENANT_ID to place calls` and rings nobody. Who gets rung follows the same reasoning. `CHAT_CALLBACK_TOOL` and `CALL_BACK_TOOL` take a `message` and nothing else: there is no target parameter on them, and there never will be. ### Where the target comes from instead `OutboundLane.remember_chat_sender(message)` records who last wrote in a conversation, reading it off the `InboundMessage` itself, and `chat_callback_target(conversation_id)` hands that back as a `ChatCallbackTarget`. | Field | What it carries | | ----------------- | ----------------------------------------------------------------------------- | | `user_object_id` | The directory id to ring. | | `tenant_id` | The organisation that message came from. | | `conversation_id` | Where the request came from, so an unanswered call still has somewhere to go. | | `display_name` | What to call them, when the message carried a name. | The message is the only source. Never the message text, never a tool parameter: an agent that can be told who to ring is an agent that can be talked into ringing anybody, and the thing doing the telling is being steered by whoever is on the other end of it. When nobody has written, or the last message is more than ten minutes old, what comes back is a sentence rather than a target: `I do not know who to call for this conversation.` or `That was a while ago. Ask me again and I can call you.` A sentence rather than an exception, because whatever reads this is a tool result a model says out loud. Check which you got before you use it. ```python theme={null} target = lane.chat_callback_target(message.conversation_id) if isinstance(target, str): return target # already safe to read out placed = await lane.place( user_object_id=target.user_object_id, text="Your build finished.", tenant_id=target.tenant_id, thread_id=target.conversation_id, ) ``` ## The hourly budget counts calls that rang `policy.record()` runs after the call is placed and a call id comes back, not when the attempt starts. Counting attempts would let a broken worker burn the whole hour on calls that never rang anybody, and the next real delivery would be refused for an hour because of it. A worker that is down costs nothing against the budget. A phone that actually rang costs one. ## What is parked, and where `PendingMessages` is on disk, and that is the part people get wrong. The leg that answers is **a different call**. You ask for the call in one place, and StandIn dials your worker minutes later with `direction="outbound"` and a fresh `callId`. A restart between the two is ordinary, and it may not even be the same process. Parked in memory, the message is lost silently: the callee picks up and hears nothing, with no error anywhere. `state_dir()`, in `standin.outbound`, is `STANDIN_STATE_DIR` when set, otherwise `~/.standin/state`, created owner-only. It is deliberately not a temp directory. A temp directory passes every test and loses every parked message on the next reboot. `VoiceDelivery` parks the record before `deliver` returns, because the leg can be answered before a later park would have run. | Field | What it carries | | -------------- | -------------------------------------------------------------------------------------------------- | | `call_id` | The id the answering leg arrives with. | | `text` | The line to speak on answer. | | `thread_id` | The conversation the request came from. | | `tenant_id` | Which tenant to post the fallback into. Never taken from a model. | | `target` | Who was rung, so a second call to the same person can be refused while the first is still ringing. | | `requested_by` | Directory id of whoever asked for the call, for the audit trail. | | `created_ms` | Stamped by `park` when the record has no time of its own. | | `attempts` | Delivery attempts so far, so a chat that keeps failing stops. | Popping is atomic. The rename is the lock, so two workers answering the same leg cannot both speak. ```python theme={null} parked = pending.pop(session.call_id) if parked is not None: await self.say(parked.text) ``` Popping the record yourself tells you what to say and not when to say it, and nothing should be said while the leg is still ringing. `OutboundLane.attach(session, speak)` is the piece that waits: [the leg that answers](#the-leg-that-answers). ### The thread id is how an unanswered call still reaches them A call is placed because somebody is owed something. If they do not pick up, they are still owed it. `thread_id` is carried on the record for exactly that: it is the conversation the request came from, so the line can be posted there instead of evaporating. `OutboundLane` owns that sweep, which runs from `lane.start()`, and it posts once even when both the ring timer and the call outcome fire for the same call. `OutboundLane.place` parks no fallback at all for a call that has no real conversation, which is what `call_thread_is_postable` decides. A one-to-one call has no meeting conversation, and posting to what that field carries instead would either fail or reach the wrong place. What does the posting is whatever you passed as `OutboundLane(chat=...)`, and `ChatSender` is the shape it has to have: one `async send(*, tenant_id, conversation_id, text, idempotency_key=None) -> bool`. `ChatChannel` already satisfies it, so the ordinary case is handing over the channel you already have. The protocol exists so that a plugin posting through something else is not forced to inherit from one. It returns whether the message went, not whether it was attempted. A `False` puts the record back for another try, and after five attempts, or once the record is older than the lane's `ttl_s`, the lane gives up in the log rather than retrying for ever. The `idempotency_key` is what makes the ring timer and the call outcome both firing for one call tell the person once. ## The leg that answers Placing a call returns a `PlacedCall`, from `OutboundCaller.place_call` and from `OutboundLane.place` alike. | Field | What it is | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `call_id` | The id the answering leg will arrive with. This is what a message is parked against. | | `scenario_id` | StandIn's own correlation id when it sends one, otherwise empty. Log it and a call in your records matches a call in a support question. | `OutboundLane.attach(session, speak)` turns that id back into an `OutboundLeg` on the call that answers, and returns `None` on an inbound one, so a plugin calls it unconditionally from `on_start` and forwards two things: ```python theme={null} class MyHandler: async def on_start(self, session: CallSession) -> None: self._leg = lane.attach(session, lambda message: self.say(message.text)) async def on_context(self, text: str) -> None: if self._leg is not None: await self._leg.on_context() async def aclose(self, reason: str) -> None: if self._leg is not None: await self._leg.aclose(reason) ``` The leg holds the parked line on `message` and speaks it through the `speak` you gave `attach`, which is handed the whole `PendingMessage` rather than its text, and which owns the wording: only your plugin knows whether its provider takes an instruction or a literal line to say. A leg that finds nothing parked waits a few seconds before concluding there is nothing to say, because the leg can be answered before `place` has finished parking, and a race lost there is a caller who picks up to silence. **Recording going active is what "they answered" means.** There is no "they picked up" message on the wire, and recording turning on is what happens when a Microsoft Teams call is actually connected, so a forwarded `on_context` is the signal. A plugin with a better one calls `answered()` directly. A pickup fast enough to beat the attach is covered too, because arming checks the recording once on the spot rather than only waiting for the next one. **The leg runs its own watchdog**, ending a call nobody answered after the lane's `answer_timeout_s`, two minutes by default. The idle watchdog cannot do this job: a ringing leg carries no caller audio by definition, so to that watchdog every outbound call looks dead from the moment it starts. **Nothing unsaid is thrown away.** If speaking raises, the line is not marked spoken, because it was not said. If the leg closes with the line still unsaid, `aclose` releases the record rather than deleting it, and the sweep posts it to chat instead. Somebody was owed something, and not picking up does not stop them being owed it. The numbers behind this lane, `DEFAULT_ANSWER_TIMEOUT_S`, `CHAT_CALLBACK_WINDOW_S` and `MAX_DELIVERY_ATTEMPTS`, are in `standin.outbound` rather than at the barrel, so import them from the module on the rare occasion you need the value rather than the argument. The TypeScript twin exports its own and names every duration in milliseconds, `DEFAULT_ANSWER_TIMEOUT_MS` against `DEFAULT_ANSWER_TIMEOUT_S` and `ttlMs` against `ttl_s`: port the duration, never the number. ## The result ```python theme={null} result = await delivery.deliver(text, target=user_object_id, thread_id=conversation_id) if not result.ok: return result.error ``` | Field | What it means | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ok` | Whether the line reached a lane that will say it. | | `mode` | `"live-call"` when it was spoken into a call that was already up, or `"call-back"` when a call was placed and the line parked. Both strings are part of this API. | | `call_id` | The call that was placed, on the call-back path. Empty otherwise. | | `error` | One sentence, safe to read out loud. Empty when `ok`. | `deliver` never raises. Whatever reads the result is either a host that marks the whole platform failed on an exception, or a model that says it out loud, and neither can do anything useful with a traceback. Three refusals come from the placement rather than the policy: | When | What comes back in `error` | | ---------------------- | --------------------------------------------------------- | | No `caller` was passed | `no outbound caller is configured on this deployment` | | The request failed | `could not place the call: ...`, with the reason appended | | No call id came back | `the call was not placed` | An empty line is refused before the registry is even consulted, with `there was nothing to say`. An empty message must never place a real phone call to a real person. # Realtime providers Source: https://docs.komaa.com/python-sdk/realtime-providers StartupBuffer and EchoGuard for a speech-to-speech agent: what arrives before you are ready, and why the agent answers itself, in the StandIn Python SDK. A realtime speech-to-speech provider is handed the caller's audio and hands audio back, and the turn-taking is theirs. That is the whole reason to use one, and it is why [Turn-taking](/python-sdk/voice) is a page you can skip: no segmenter, no paced playback, no pipeline of three vendors to keep in step. This page is the other half of that bargain. The provider owns the turn. It does not own the two things on either side of it: * **what arrived before your provider was ready to take it**, which the provider cannot hold because it did not exist yet * **whether the audio coming back is somebody else's voice or your own**, which the provider cannot know because it only sees one stream Neither is a property of any framework, so neither lives in a plugin. Both are here. ```python theme={null} from standin import ( ECHO_BARGE_IN_RMS, ECHO_SUPPRESSION_WINDOW_MS, MAX_PENDING_AUDIO, MAX_PENDING_CONTEXT, EchoGuard, StartupBuffer, pcm16_rms, ) ``` Every name on this page is exported from `standin`. If you would rather import the modules, the buffer and its two caps live in `standin.startup` and the guard and its two constants in `standin.echo_guard`. `pcm16_rms` is defined once in `standin.audio` and re-exported from `standin.echo_guard`, because an echo guard, a barge-in check and a voice segmenter all want it and there must not be three copies of it. ## What arrives before your agent is ready The call starts the moment StandIn dials. Your agent starts later: a socket has to open, a room has to be joined, a session has to be configured, an agent job has to be dispatched and accepted. `on_start` covers part of that gap for you, because audio is not delivered while it runs. It does not cover the rest of it. A socket that is open is not a session that is configured, a room you have joined is not an agent that has joined it, and a handler that returns from `on_start` rather than holding the call open has handed itself the same gap deliberately, which is often the right call: `on_start_timeout` is 15 seconds and a caller waiting on a slow provider hears dead air. See [on\_start](/python-sdk/call-handler#on_start). Two things land in that window and both matter. **The caller's first words.** People start talking the instant the call connects, and the first thing they say is very often the reason they called. Drop it and the agent opens by asking a question that was already answered, which is the single most common way a demo call goes wrong. **The first context.** The `"There are 4 human participants on this call. Stay quiet unless directly addressed."` line and the `"The Microsoft Teams call recording is now ACTIVE."` change both land in that same first moment. Drop those and a [group gate](/python-sdk/group-calls) never engages, so the agent answers every turn of a meeting, and a recording gate never opens, so an agent that waits to be told the call is recorded waits for the whole call. Neither failure looks like a dropped message. Both look like a feature that was never built. Gate your own storage on `session.recording_active`, which the SDK keeps current from `session.start` and every later status change, rather than on a sentence you happened to catch. What the buffer protects is the copy that reaches your **provider**, which has no other way to learn either fact. ```python theme={null} from standin import CallSession, StartupBuffer class MyHandler: def __init__(self) -> None: self._agent = None self._pending = StartupBuffer() async def on_start(self, session: CallSession) -> None: self._agent = await connect_to_provider() await self._pending.release( send_audio=self._agent.send_audio, send_context=self._agent.send_context, ) if any(self._pending.dropped): logger.info("standin: the caller outran the agent starting up") async def on_caller_audio(self, pcm: bytes) -> None: if self._pending.holding: self._pending.audio(pcm) else: await self._agent.send_audio(pcm) async def on_context(self, text: str) -> None: if self._pending.holding: self._pending.context(text) else: await self._agent.send_context(text) ``` | Member | What it does | | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `StartupBuffer(max_audio=MAX_PENDING_AUDIO, max_context=MAX_PENDING_CONTEXT)` | One per call. Each bound is floored at 1. | | `holding` | Whether the agent is still being set up. False after `release` or `discard`. | | `audio(pcm)` | Hold one frame of the caller's voice. Empty input is ignored. | | `context(text)` | Hold one line of call context. Empty input is ignored. | | `await release(send_audio=None, send_context=None)` | Hand everything over, in order, and stop holding. Returns `(audio frames, context lines)`. | | `dropped` | What was lost to the bounds, as `(audio frames, context lines)`. | | `discard()` | Throw it away, for a call that ended before the agent was ready. | Order is preserved within each lane, and **audio is released before context**. The provider needs the caller's words in the order they were said; the context is a note about the call rather than part of the conversation, so it belongs after the words and not interleaved with them. Both callbacks may be sync or async, and each is awaited only if it returns something awaitable. A provider's send is often fire-and-forget, and forcing a coroutine on one that is not is how a plugin ends up wrapping every send in a lambda. Either callback may be omitted, for a lane your provider has no route for: the buffer still drains and still stops holding, it just sends nothing. `release` is safe to call twice: the second call releases nothing and returns `(0, 0)`. That matters on the failure paths, where `on_start` can end early in more than one place. ## Audio first, context second, oldest dropped first Two caps, both defaults, both overridable per buffer: | Constant | Value | What it is | | --------------------- | ----- | --------------------------------------------------------------------------------------------- | | `MAX_PENDING_AUDIO` | `200` | Frames of the caller's voice. At the wire's 20 ms frame that is **4 seconds** of speech. | | `MAX_PENDING_CONTEXT` | `20` | Lines of call context. Context sentences are rare and each one is small, so this is generous. | Four seconds is enough to hold an opening sentence and far short of enough to hide a socket that never opened. That is the whole calculation. A provider that is going to connect has connected by then; a provider that is not going to connect must not be allowed to accumulate the caller's entire call in memory while it fails to, on every concurrent call the worker is carrying. When the cap is reached, the **oldest** entry goes and the newest is kept. If something has to be lost, lose the stale audio: the words the agent is about to answer are the ones the caller just said, and an agent that opens on a four-second-old fragment is worse than one that opens on the most recent sentence. Log a non-zero `dropped`. It is the one signal that tells you the caller outran your provider's startup, and there is no other place it shows up: the call sounds normal, the agent answers, and the only trace is the sentence nobody accounted for. Treat it as a startup-latency alarm rather than an error. `discard()` exists for the call that ends before the agent is ready. Use it in `aclose` when `holding` is still true, rather than releasing into a provider you are about to close. ## The agent answering itself `EchoGuard` decides, per inbound frame, whether the caller's audio reaches the model. On a speakerphone, and on any laptop whose user has not got a headset on, the agent's own voice comes out of the speaker and goes straight back into the microphone. It arrives at your handler as caller audio. The provider's voice detection hears a turn, ends it, and replies to it. Then that reply loops back, and it replies to that. The caller is silent throughout and cannot get a word in, and it continues until somebody hangs up. It is not a subtle degradation, it is the call being taken over. **The two SDKs are not the same shape here.** This SDK has an `EchoGuard` **class** that owns its own playout clock. The TypeScript SDK has **no class**: it has one pure function, `shouldSuppressEcho`, the clock is yours to keep, and its polarity is inverted, `true` there meaning **drop the frame** where `allow_input` here returns `True` to **pass** it. Nothing about `EchoGuard` ports across as written. The differences are tabulated at the end of this page. ```python theme={null} from standin import ( REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ, EchoGuard, FrameAligner, frame_duration_ms, pcm16_rms, resample_pcm16, ) class MyHandler: def __init__(self) -> None: # enabled=True, tail_window_ms=600, barge_in_rms=0.04 self._echo = EchoGuard() self._aligner = FrameAligner() async def on_caller_audio(self, pcm: bytes) -> None: if not self._echo.allow_input(pcm16_rms(pcm)): return await self._client.append_input( resample_pcm16(pcm, SAMPLE_RATE_HZ, REALTIME_SAMPLE_RATE_HZ) ) async def _on_model_audio(self, pcm24: bytes) -> None: pcm = resample_pcm16(pcm24, REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ) for frame in self._aligner.push(pcm): await self._call.send_audio(frame) # Duration, not frame count: it is what the guard compares # against wall time. self._echo.note_output(frame_duration_ms(frame)) ``` | Member | What it does | | ------------------------------------------------------------------- | ------------------------------------------------------------------- | | `EchoGuard(*, enabled=True, tail_window_ms=600, barge_in_rms=0.04)` | Keyword only. One per call. | | `note_output(duration_ms, now=None)` | Record that this much of our audio was handed to StandIn. | | `speaking(now=None)` | Is our own audio still likely to be audible at the caller's end? | | `allow_input(rms, now=None)` | Should this inbound frame reach the model? | | `collapse(now=None)` | A barge-in was accepted: stop treating inbound audio as echo. | | `mark_caller_turn()` | The caller has spoken a real turn; barge-in is allowed from now on. | `ECHO_SUPPRESSION_WINDOW_MS` is `600` and `ECHO_BARGE_IN_RMS` is `0.04`, the same values as the constructor defaults, exported so configuration and tests can name them rather than repeat them. What `allow_input` does, in full: | State | Outcome | | ----------------------------------------------------- | ------------------------- | | `enabled=False` | Through. | | Not speaking | Through. | | Speaking, before the caller's first turn | Dropped, at any loudness. | | Speaking, after the first turn, `rms < barge_in_rms` | Dropped. | | Speaking, after the first turn, `rms >= barge_in_rms` | Through. | `pcm16_rms` returns root-mean-square amplitude normalised to `0.0` to `1.0`, and it is dependency-free because it runs on every inbound frame of every call. See [Audio](/python-sdk/audio). `enabled=False` is a real option, not a test hook. On a headset-only deployment there is no acoustic path from the speaker back into the microphone, so the guard has nothing to catch and every frame it filters is a barge-in the caller does not get. ## A playout clock, not a send clock A realtime model streams its answer far faster than realtime. Five seconds of speech can be handed over in a fraction of a second, and a call consumes audio at one second per second, so the rest of it is still buffered and still being heard long after the send returned. So wall-clock send time is useless here. A guard keyed on "did we send something recently" disarms a few hundred milliseconds into a sentence that is still coming out of the caller's speaker, which is the exact moment the echo it exists to catch starts arriving. `note_output(duration_ms)` accumulates the **duration** of what was sent instead, and `speaking()` asks whether now is still before that playout horizon plus the tail: ```python theme={null} self._playout_end_ms = max(now, self._playout_end_ms) + duration_ms ``` The `max(now, ...)` rather than a bare addition is the part worth reading twice. After a gap in speaking, the old horizon is already in the past, and adding to a stale horizon leaves the clock permanently behind real time, so the guard never arms again for the rest of the call and the failure looks like a guard that was never switched on. Pass `frame_duration_ms(frame)` rather than counting frames. Outbound chunk lengths are not fixed, and a frame count drifts against real time in exactly the direction that hurts. ## No barge-in until the caller's first real turn Until `mark_caller_turn()` has been called once, `allow_input` drops every frame while `speaking()` is true, however loud it is. That is not a conservative default, it is the specific case. The agent's opening greeting is the first thing on the call, it echoes back, and it is loud, because a greeting is spoken at full volume into a room nobody is talking over yet. Without this rule the loudest possible echo arrives at the exact moment the guard has no history to compare it against, the agent treats it as a barge-in, interrupts itself, and greets itself again. And again. Call `mark_caller_turn()` where you know a real turn happened: on a finished caller transcript, and on a barge-in your provider reported and you accepted. It is a latch, so calling it repeatedly costs nothing. ## Collapse on an accepted barge-in When you accept a barge-in you cancel the playback StandIn still holds. The guard does not know that. It counted that audio as if the caller would hear all of it, so its horizon is still sitting several seconds in the future. Without `collapse()` the guard therefore keeps filtering the caller's microphone for the length of the buffer you just threw away, which is precisely the words they interrupted you to say. The caller interrupts, the agent stops, the caller talks, and the agent hears nothing. ```python theme={null} async def _on_barge_in(self) -> None: await self._call.cancel_playback() # first: un-send what StandIn still holds self._aligner.reset() # the residual belongs to a dead turn self._echo.collapse() # the horizon covers audio nobody will hear self._echo.mark_caller_turn() # this was a real turn await self._client.cancel_response() # then: stop the model generating more ``` `collapse()` pulls the horizon to `now - tail_window_ms`, so `speaking()` is false immediately rather than at the end of a window. Call it anywhere you call `cancel_playback()`, which includes `on_goodbye`: a goodbye cancels playback too, and a guard left armed after one spends the last seconds of the call filtering the caller's reply to it. See [Barge-in](/python-sdk/call-handler#barge-in-and-cancel-playback). ## The tail belongs to your provider `tail_window_ms` is a constructor argument and not a constant, and that is a deliberate line. It covers the network and the jitter buffer between you and the caller's speaker, and then however long the provider's own voice detector keeps hearing you after the audio stops. The first part is the wire and is the same for everybody. The second is a property of that provider, and providers differ. `600` is the default and the value in `ECHO_SUPPRESSION_WINDOW_MS`. Move it with the failure you actually see: * Too short and the tail of your own sentence gets through as a turn, and the agent answers its own last three words. * Too long and a genuine interruption in the moments after you stop speaking is dropped, and the caller has to say it twice. `barge_in_rms` is the same kind of knob on the same kind of trade. `0.04` on the `0.0` to `1.0` scale `pcm16_rms` returns. Raise it in a loud room and you buy fewer false barge-ins with a real cost: the case it then gets wrong is a quiet caller trying to interrupt a loud agent, which is the caller who most needs the interruption to work. Tune both per provider, keep them in your plugin's configuration rather than in its code, and treat the defaults as a starting point that was measured on a call rather than a constant that was derived from one. ## What is not the same in the TypeScript SDK `StartupBuffer` is the same object in both, with the same caps and the same two lanes. One detail is language-shaped: `dropped` and the return of `release` are tuples here, `(audio, context)`, and objects there, `{ audio, context }`. The echo guard is genuinely different, and a port that assumes otherwise runs and then fails on a real call. | | Python SDK | TypeScript SDK | | ----------------- | ---------------------------------------------------- | ---------------------------------------------------- | | Shape | `EchoGuard`, a class holding per-call state | `shouldSuppressEcho`, a pure function | | Polarity | `allow_input(...)` is `True` to **pass** the frame | `shouldSuppressEcho(...)` is `true` to **drop** it | | Input | a loudness float you computed yourself | the PCM16 buffer, measured for you | | The playout clock | inside the object, fed by `note_output(duration_ms)` | yours, passed as `playbackActiveUntil` | | Clock source | monotonic milliseconds | epoch milliseconds, from `Date.now()` | | Accepted barge-in | `collapse()`, disarms immediately | assign the horizon yourself, tail window still armed | | First caller turn | `mark_caller_turn()`, a latch on the object | `allowBargeIn`, an option passed per frame | | Disabling it | `EchoGuard(enabled=False)` | `suppressInputDuringPlayback: false` | The polarity row is the one that costs a call. Reading `allow_input` as "should I suppress this" mutes the caller for the whole call and passes the echo, which is the exact failure inverted. The constants are the same in both and carry the same names, `ECHO_SUPPRESSION_WINDOW_MS` at `600` and `ECHO_BARGE_IN_RMS` at `0.04`, so configuration and tests can name them rather than repeat them. ## Next The gate that keeps the agent out of a meeting it was not addressed in. A full realtime handler, with the resampling, the alignment and the barge-in. The other half: an agent built from separate transcription, model and speech. `frame_duration_ms`, `pcm16_rms`, the resampler and the frame aligner. # Security Source: https://docs.komaa.com/python-sdk/security HMAC v1 and v2 in the StandIn Python SDK: the handshake lane, the body lane, the replay windows, and what the SDK refuses. Every StandIn connection is authenticated with HMAC-SHA256 over your connection secret. There are **two lanes**, they sign different things, and they have different replay windows. They are both real, and neither is a fix for the other. | Lane | Signs | Window | Header | | ------------ | ---------------------------------------------------------------------- | ------ | ------------------------ | | v1 handshake | `"{timestampMs}.{id}"`, where `id` is the channel name or the `callId` | 60 s | `X-StandIn-Signature` | | v1 body | `"{timestampMs}."` followed by the exact body bytes | 300 s | `X-StandIn-Signature` | | v2 request | `"{timestampMs}."` followed by `METHOD\npath\nsha256(body)` | 60 s | `X-StandIn-Signature-V2` | The timestamp always travels in `X-StandIn-Timestamp`, in milliseconds. Signatures are lowercase hex. ## Why the lanes differ A WebSocket upgrade has no body worth signing. What identifies it is the thing in the URL, so the signature binds the `callId` (inbound) or the channel name (outbound), and the window is tight: handshakes are dialed and answered immediately, so 60 seconds is already generous, and anything older is a replay. A POST has a body, and the body is the whole message. Signing anything less would leave the payload unauthenticated, so the body lane signs the exact transmitted bytes, and the window is 300 seconds because **chat relay retries** are legitimately delayed. That 300 seconds belongs to the v1 body lane and to nothing else. v2 goes further and binds the method and the path as well as the body hash, so a signature captured for one endpoint cannot be replayed against another. Control requests should use v2. The only v2 verifier in this SDK is the worker's own outcome route, the one `on_call_outcome` opens, and it checks freshness in the **60-second** handshake window rather than the relay window: a control request is issued and answered immediately, like a dial. See [Outbound call outcomes](/python-sdk/call-server#outbound-call-outcomes). Do not present one lane as a fix for the other. A body signature does not protect a WebSocket upgrade, and a handshake signature does not authenticate a POST body. Sign the thing you are actually sending. ## The inbound call handshake StandIn dials `wss:///msteams/calling/{callId}` with two headers. `CallServer` verifies them before the upgrade completes, and you write no code for this. ``` X-StandIn-Timestamp: 1757500000000 X-StandIn-Signature: ``` The `callId` that is signed is the URL path segment. That matters later: when `session.start` arrives with a `callId`, the server checks it against the authenticated path and closes the call if they disagree. A body that disagrees with the path is either a bug or an attempt to ride one call's signature into another's session. It is also why `session.call_id` is trustworthy: it is the value the HMAC signed, not something a caller supplied. ## The replay guard Verifying the signature is not enough on its own. A correctly signed upgrade, captured and replayed inside the 60-second window, would otherwise open a second socket. So `CallServer` keeps each accepted handshake as single-use. Three details in that guard are worth knowing, because each closes a real gap: **The fingerprint uses the normalized signature.** Verification accepts case and whitespace variants, so keying the cache on the raw header would let the same capture replay once per casing. **Entries age from the signing timestamp, never from arrival.** Verification accepts a timestamp up to the window in the **future**, so an entry aged from arrival could be pruned while its signature was still valid, reopening the exact replay the guard exists to close. Aged from the signing time, an entry lives precisely as long as the signature does. **Pruning is throttled by time, not triggered by size.** Rebuilding the map once it passes a watermark would make every later request O(n). Only correctly signed, not-yet-seen handshakes reach the cache at all, since a bad signature is rejected earlier and a replay returns before it, so the map tracks StandIn's real call rate rather than attacker traffic. A replay gets `401 handshake already used`. ## What the SDK refuses `verify_handshake` and `verify_body` fail **closed**. They return `False`, never raise, and never tell the caller which part was wrong. | Input | Result | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Missing secret, timestamp or signature | Rejected. | | A timestamp that is not ASCII decimal | Rejected. `int()` alone also accepts underscores and Unicode digits, so the check is an explicit regex, matching the TypeScript SDK. | | A timestamp outside the window, in either direction | Rejected. | | A signature containing a non-ASCII character | Rejected. `compare_digest` raises `TypeError` on non-ASCII strings, and this path is reachable by anyone who can open a socket. Unguarded, a header of `"ünicode"` would turn an unauthenticated upgrade into a 500 rather than a 401, which is both a crash path and an oracle distinguishing "malformed" from "wrong". | | A wrong signature | Rejected, after a constant-time comparison. | Comparison is constant-time in every case that reaches it. ## Signing helpers All of these prepare signatures. None of them send anything. ```python theme={null} from standin import ( SIGNATURE_HEADER, # "X-StandIn-Signature" SIGNATURE_V2_HEADER, # "X-StandIn-Signature-V2" TIMESTAMP_HEADER, # "X-StandIn-Timestamp" REPLAY_WINDOW_MS, # 60000 CHAT_REPLAY_WINDOW_MS, # 300000 canonical_request, now_ms, sign_body, sign_handshake, sign_request, verify_body, verify_handshake, ) ``` ### sign\_handshake and verify\_handshake ```python theme={null} sign_handshake(secret, timestamp_ms, handshake_id) -> str verify_handshake(secret, timestamp, handshake_id, signature, current_ms=None) -> bool ``` The WebSocket lane, 60-second window. `handshake_id` is the `callId` when StandIn dials you, and the channel name when your worker dials the chat channel. `ChatChannel` signs with it on your behalf; `CallServer` verifies with it on your behalf. ### sign\_body and verify\_body ```python theme={null} sign_body(secret, timestamp_ms, raw_body) -> str verify_body(secret, timestamp, raw_body, signature, current_ms=None, window_ms=300_000) -> bool ``` The chat POST lane, 300-second window. The window is a parameter with a default rather than a constant, so the 300 seconds follows the body lane and does not leak into anything else. It signs `"{timestampMs}."` followed by the exact body bytes. Serialize the body once and send those same bytes. Parsing and re-serializing JSON can change whitespace, key order or Unicode escaping, and then the signature you sent no longer matches the bytes you sent. ```python theme={null} import json from standin import SIGNATURE_HEADER, TIMESTAMP_HEADER, now_ms, sign_body raw_body = json.dumps(payload, separators=(",", ":")) # serialize once timestamp = str(now_ms()) headers = { TIMESTAMP_HEADER: timestamp, SIGNATURE_HEADER: sign_body(secret, timestamp, raw_body), "Content-Type": "application/json", } # send raw_body itself, not a re-serialized copy ``` ### canonical\_request and sign\_request ```python theme={null} canonical_request(method, path, raw_body) -> str # "METHOD\npath\nsha256_hex(body)" sign_request(secret, timestamp_ms, method, path, raw_body) -> str ``` v2, for HTTP control requests. `path` is the request path, without the origin and without the query string, matching the worker verifier. ```python theme={null} from standin import SIGNATURE_V2_HEADER, TIMESTAMP_HEADER, now_ms, sign_request timestamp = str(now_ms()) headers = { TIMESTAMP_HEADER: timestamp, SIGNATURE_V2_HEADER: sign_request(secret, timestamp, "POST", "/api/calls", raw_body), } ``` The raw body covers every field, including `tenantId`, which is what selects the organisation to act on. Binding method and path as well means a signature for one endpoint is useless against another. ## What the secret does not decide The handshake proves the dial came from StandIn. That is all it proves. **Authentication is not authorization.** `verify_handshake` says the socket is genuine; it says nothing about whether this particular caller may be answered. Who is allowed to reach your agent is deployment policy, and it lives in your handler. ```python theme={null} async def on_start(self, session: CallSession) -> None: caller = session.start.caller if caller.aad_id not in ALLOWED: await session.end("caller-not-allowed") return ``` `caller.aad_id` is `None` for guest and anonymous callers. Write the test as `caller.aad_id not in ALLOWED`, never as `if caller.aad_id and caller.aad_id not in ALLOWED`, which fails **open** for exactly the callers you least want to admit: an anonymous caller can never match an allowlist, so a guard that skips the check when the id is missing admits everyone it cannot identify. The TypeScript SDK ships inbound-policy helpers in the package (`isInboundCallAllowed` and its companions). A Python deployment writes the check in its handler instead, which is the snippet above. See [Refusing a call](/python-sdk/call-handler#refusing-a-call). ## The outbound fetch guard An agent that can show the caller a picture will sooner or later be handed a URL by its own model, and that model is steered by whoever is on the call. So the URL is untrusted input wearing a trusted costume, and a plain `GET` of it is a server-side request forgery: `169.254.169.254` is cloud credentials, `127.0.0.1` is whatever else you run, and `10.0.0.0/8` is the rest of your network. ```python theme={null} from standin.fetch import assert_public_http_url, fetch_public_image, is_forbidden_ip ``` | Name | What it does | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `await assert_public_http_url(url)` | Returns the URL, or raises `ValueError` naming the problem: a scheme that is not http or https, embedded credentials, no host, or a host that resolves into forbidden space. | | `is_forbidden_ip(address)` | The address test on its own, for a check of your own. Private, loopback, link-local, carrier-grade NAT, multicast and reserved ranges. Anything it cannot parse is forbidden too, because something that is not an address cannot be proven safe. | | `await fetch_public_image(url, max_bytes, timeout_ms=10_000)` | The whole guarded fetch, which is what the vision tools use. Returns `(bytes, mime)`, and raises `ValueError` with a reason you can hand straight back to the model that supplied the URL. | The first and the third are **coroutines**, because the guard has to resolve the host before it can judge it. Awaiting them is the check; calling one without `await` builds a coroutine, validates nothing, and is truthy, so a guard written as `if assert_public_http_url(url):` passes every URL there is. The subtle half is the rebind. Validating a hostname resolves it once, and the resolution the HTTP client does a moment later can answer differently, so the address the socket **actually** connects to is re-checked against the same rules. One redirect hop is followed, because image CDNs habitually redirect to the real asset, and the target goes through the whole guard again rather than being trusted for having come from a host that passed. Link-local is on the list by name, because that is where cloud metadata lives. These three are top-level exports in the TypeScript SDK and live in `standin.fetch` here. See [Vision and the avatar](/python-sdk/vision#fetching-an-image-a-model-chose) for the image case. ## The generated wire builders `standin.protocol` is generated from the schema and carries the frame builders and parsers themselves: `audio_frame`, `session_end`, `pong`, `assistant_cancel`, `parse_message`, `parse_session_start` and `decode_pcm`. `assistant_cancel` is the only one re-exported from the `standin` barrel; the rest are `from standin.protocol import ...`. They are public because the wire is public, and they are what the shared conformance vectors assert across both languages. A handler should still not reach for them: `CallServer` owns the sequence number and the outbound timeline, and a hand-built frame is how those two stop agreeing. ## Operational notes **Clock skew is authentication.** Both windows are absolute differences, so a worker whose clock drifts more than 60 seconds stops accepting calls. Run NTP. **Terminate TLS at your ingress.** StandIn dials `wss://`, and the handshake headers are only as private as the transport carrying them. **The secret is the credential.** It must byte-match the portal value. Keep it in the environment, never in a source file or an image layer, and rotate it in the portal if it is ever printed in a log. **Bind narrowly when you can.** `STANDIN_HOST=127.0.0.1` keeps the listener off every other interface when a local tunnel is the only thing that should reach it. The upgrade is authenticated either way, but there is no reason to offer more surface. **Capacity is checked before crypto.** A flood cannot make the worker spend CPU verifying signatures for calls it was never going to accept. ## Conformance Both SDKs are generated from `protocol/schema.yaml` and checked against shared vectors in `protocol/conformance.json`, the HMAC cases included. The Python and TypeScript implementations sign identically, byte for byte, which is what lets a Python worker and a TypeScript worker share one StandIn identity. The protocol modules are generated and drift-gated, never hand-edited. ## Next Where the handshake and the replay guard are enforced. The outbound lane, where your worker is the one signing. # Sending a picture Source: https://docs.komaa.com/python-sdk/sending-pictures Attach a picture to a Microsoft Teams message as checked bytes, and turn an agent's MEDIA: marker into one, in the StandIn Python SDK. A reply can carry one picture. It travels as bytes, and every helper on this page exists for the same reason: the thing that chose the picture, the type, the filename or the path is usually a model, and that model is steered by whoever is in the conversation. This page is about a picture your agent **sends into a chat**. Putting one on the bot's video tile during a call is a different lane with different limits and a different variable: see [Vision and the avatar](/python-sdk/vision). ## Bytes, never a link ```python theme={null} from standin import outbound_image image = outbound_image(png_bytes, "image/png", name="q3-revenue.png") ``` `outbound_image(data, content_type, name=None)` returns an `OutboundImage` with three fields: `content_type`, `content_base64`, and an optional `name`. `data` is `bytes`, or a base64 `str` that is decoded strictly. `as_json()` is the wire form, and `build_reply` and `ChatChannel.send` call it for you. A link is a beacon. An off-domain image loads with no click, under this bot's name, and what it serves can be swapped after anyone looked at it. Bytes can be checked, and the rest of this page is those checks. Attach one to a message you are posting yourself: ```python theme={null} await chat.send( tenant_id=msg.tenant_id, conversation_id=msg.conversation_id, text="Here is the quarter.", image=image, binding_id=msg.binding_id, idempotency_key=f"{msg.activity_id}:picture", ) ``` Or, if you are receiving the relay yourself over the POST lane rather than through `ChatChannel`, put both in one reply: ```python theme={null} from standin import build_reply reply = build_reply(msg, "Here is the quarter.", image=image) ``` A `ChatChannel` handler returns **text**, so a picture goes out as its own message through `chat.send` before you return, and it lands **before** the words. Do not return an empty string afterwards to stop the second message: the channel reads an empty answer as a failed turn and posts `I couldn't come up with an answer to that` behind your picture. Give the picture a caption when you have one, and always return words. If you need the caption and the picture in one message, build the reply yourself with `build_reply`. `build_reply(message, text, kind="message", image=None)` is the whole signature, and the image rides the same message as the text. Call it with `kind="typing"` and it drops both: a typing indicator is a state, not a message. ## A declared type is a claim `outbound_image` runs three checks, in this order. 1. The declared type is one of `OUTBOUND_IMAGE_CONTENT_TYPES`: `image/png`, `image/jpeg`, `image/gif`, `image/webp`. `image/jpg` is normalised to `image/jpeg` first, because it is a common spelling and not a media type. `image/svg+xml` is deliberately absent and is not a gap to fill later: SVG is scriptable XML, which is precisely what an image must not be. 2. The decoded bytes are at most `OUTBOUND_IMAGE_MAX_BYTES`, which is `1024 * 1024`. 3. `sniff_image_type(raw)` equals the declared type, or the call raises `ValueError`: `those bytes are not image/png: they look like image/gif`. The third check is the one that matters. A declared type is a claim made by whatever produced the bytes. Without the signature check, an HTML document or an SVG labelled `image/png` is posted into somebody's chat under this bot's name, and the reader has no reason to distrust it. Both constants come from the package root, so a check of your own uses the number the SDK uses rather than a copy that drifts: ```python theme={null} from standin import OUTBOUND_IMAGE_CONTENT_TYPES, OUTBOUND_IMAGE_MAX_BYTES ``` ```python theme={null} from standin import sniff_image_type sniff_image_type(data) # "image/png", "image/jpeg", "image/gif", "image/webp", or None ``` | Type | What the bytes must begin with | | ------------ | ---------------------------------------- | | `image/png` | `\x89PNG\r\n\x1a\n` | | `image/jpeg` | `\xff\xd8\xff` | | `image/gif` | `GIF87a` or `GIF89a` | | `image/webp` | `RIFF`, and bytes 8 to 11 must be `WEBP` | That last condition is the reason `sniff_image_type` exists rather than a four-line prefix table. RIFF is a container format, not a picture format: a WAV file also begins `RIFF`, so a RIFF container is accepted only when the bytes themselves say `WEBP`. `RIFF____WAVEfmt ` sniffs as nothing at all, which is the correct answer. ## The filename is a download ```python theme={null} from standin import sanitize_image_name sanitize_image_name("../../etc/passwd.png") # "passwd.png" sanitize_image_name("C:\\Users\\x\\plan.png") # "plan.png" sanitize_image_name("..") # None ``` `sanitize_image_name(name)` reduces a name to one path-free printable segment, or `None`: * backslashes are read as separators and only the last segment survives, so no name can carry a path; * non-printable characters are dropped, and so are `<`, `>`, `:`, `"`, `|`, `?` and `*`; * an empty result, `.` and `..` all become `None`; * anything over 200 characters is truncated with its extension kept. `outbound_image` applies it to the `name` you pass, so you never need to pre-clean one. The reason it is applied at all: the filename reaches a chat as a **download** under this bot's identity, and the model that chose it is being steered by whoever is in the conversation. A name that reads as a path, or that hides its real extension behind unprintable characters, is a name somebody chose for you. ## MEDIA markers Some agent frameworks let a reply name a file by writing a line like `MEDIA:/tmp/chart.png`, and expect the channel to attach it. The marker is an instruction to the channel, not something anyone should see. A channel that does not understand the convention posts that line as prose, so a caller reads a temporary file path in their chat, and on a call it is worse: text-to-speech reads the path out, character by character. ```python theme={null} from standin import parse_media media = parse_media(answer) media.text # what to post AND what to say media.refs # ("/tmp/chart.png",), in the order they were written ``` `parse_media(reply)` returns a frozen `AgentMedia` with exactly those two fields. The text it hands back is what to post **and** what to say, both, always: the whole point is that nobody sees or hears the marker. The marker is case-insensitive and matches at most once per line: `MEDIA:`, `media:` and `Media: ` all work. It is anchored to the **end** of its line rather than split on whitespace, so `/tmp/a b.png` survives with its space intact, and backticks around the reference are stripped, so a model that formats the path as code still produces a usable one. Removing a line leaves the blank line it sat on, so three or more consecutive newlines collapse to two; a deliberate paragraph break is left alone, because it already is one. Because the marker runs to the end of its line, nothing may follow the path there. `MEDIA:/tmp/chart.png and here it is` makes the trailing sentence part of the reference: the words vanish from the reply and `load_media` then fails on a file that does not exist. Put the marker on a line of its own. ## Only a line that looks like a reference is removed ```python theme={null} parse_media("MEDIA: we should talk to them about it") # text unchanged, refs == () ``` What follows the marker counts as a reference when it starts with `http://`, `https://`, `/`, `./`, `../` or `~/`, or with a Windows drive letter, or when it ends in `.png`, `.jpg`, `.jpeg`, `.gif` or `.webp`. Everything else is prose, and the whole line stays exactly where it was. That narrowing is deliberate. Stripping every line that merely begins with the marker eats a sentence like "MEDIA: we should talk to them" out of an answer, and the person who wrote it would never learn why: they would see a reply with a hole in it and no error anywhere. ## Turning a reference into a picture ```python theme={null} from standin import load_media image = await load_media(ref) # or roots=[...], max_bytes=..., name=... ``` `load_media(ref, *, roots=None, max_bytes=OUTBOUND_IMAGE_MAX_BYTES, name=None)` turns one reference into an `OutboundImage`, through the same checks as everything else here. It raises `ValueError` with a sentence worth reading, because a caller on this path can hand the reason straight back to the agent that supplied the reference. An `http` or `https` reference is fetched through the SDK's own public-address guard, not with a plain `GET`. A URL an agent chose is untrusted input wearing a trusted costume: `169.254.169.254` is cloud credentials, `127.0.0.1` is whatever else you run, and `10.0.0.0/8` is the rest of your network. The guard accepts http and https only, refuses embedded credentials, rejects any host that resolves into private, loopback, link-local or reserved space, re-checks the address at connect time so a name that answers publicly once and privately a moment later is still refused, and follows at most one redirect hop, putting the target through the whole guard again. The budget is 10 seconds and the same byte cap as the picture it becomes. Anything else carrying a scheme is refused **by its own name**: `file:///etc/passwd` raises `file references are not allowed here`, `data:...` raises `data references are not allowed here`, `s3://...` raises `s3 references are not allowed here`. Handing a `file` scheme to a URL fetcher is the usual way around a path guard, and naming the scheme in the refusal is what stops the next reader from assuming it silently fell through to the path branch. Whatever arrives, the bytes are sniffed again at the end, and the sniffed type is what gets sent. A content type from a response header, or a file extension, is a claim; the bytes are the fact. A URL served as one type whose bytes are another is refused and the message says both: `that was served as image/png but the bytes are image/gif`. `application/octet-stream` is the one declared type accepted whatever the bytes turn out to be, because it is what it says it is: no claim at all. A response with **no** `content-type` header at all is read as `image/jpeg`, so a PNG served bare is refused with `that was served as image/jpeg but the bytes are image/png`. Both SDKs do this. If you control the server, send the real type or `application/octet-stream`; if you do not, fetch it yourself and pass the bytes to `outbound_image`. ## Local files are off until you name a directory ```bash theme={null} export STANDIN_MEDIA_ROOTS=/srv/charts:/srv/exports ``` ```python theme={null} from standin import MEDIA_ROOTS_ENV, media_roots MEDIA_ROOTS_ENV # "STANDIN_MEDIA_ROOTS" media_roots() # () until the variable is set media_roots(["/srv/charts"]) # or pass them explicitly ``` `MEDIA_ROOTS_ENV` is exported so a check of your own reads the same variable name the SDK does rather than a copy that drifts, and the TypeScript SDK exports it under the same name. An explicit list wins outright: give one to `media_roots()`, or to `load_media(ref, roots=[...])`, and the environment is not consulted at all on that call. `media_roots()` reads `STANDIN_MEDIA_ROOTS`, separated by `os.pathsep`, and returns the directories a local reference may be read from. It is **empty by default**, so a local reference is unavailable until an operator opts in, and until then `load_media` refuses with `sending a local file is off until a directory is named in STANDIN_MEDIA_ROOTS`. That default is the whole point. An agent can be talked into writing `MEDIA:/etc/passwd`, and the answer to that has to be a refusal rather than a file read followed by an upload into somebody's chat. Four details make the guard hold once a root is named: * `~` is expanded and each root is resolved through symlinks. A root that does not exist is dropped, because it cannot contain anything. * The file is resolved through symlinks too, so it is judged by where it lands rather than by how it is spelled. A symlink sitting inside a root that points at a file outside it is refused. * The prefix compare is separator-terminated. Without that, `/srv/shared-evil` passes for `/srv/shared`. * The size is read with a `stat` **before** the file is opened. The point of a cap is not to load it. Putting the two halves together in a handler: ```python theme={null} from standin import InboundMessage, load_media, parse_media from standin.log import logger async def on_message(msg: InboundMessage) -> str: media = parse_media(await agent.answer(msg.text)) for ref in media.refs: try: image = await load_media(ref) except ValueError as err: # Worth logging: the agent chose this reference, and the reason is # a sentence it can act on next turn. logger.warning("standin: could not attach %s: %s", ref, err) continue await chat.send( tenant_id=msg.tenant_id, conversation_id=msg.conversation_id, text="", # the words follow, in what this handler returns image=image, binding_id=msg.binding_id, idempotency_key=f"{msg.activity_id}:{ref}", ) # A reply that was ONLY a marker leaves media.text empty, and the channel # reads an empty answer as a failed turn. Never return the empty string. return media.text or "Here it is." ``` `logger` is not re-exported at the package root: it lives in `standin.log`, and it is the same logger the SDK writes its own lines to. ## Next The lane a picture travels on, and what it echoes back. Every variable the SDK reads, including the roots above. # Vision and the avatar Source: https://docs.komaa.com/python-sdk/vision What the caller shows your agent, what your agent shows back, and the face the caller sees, in the StandIn Python SDK. A Microsoft Teams call carries more than voice. StandIn samples the caller's camera and their screen share and forwards single frames to your worker, and it will draw an image you send onto the bot's own tile. Both halves are optional. A plugin that only wants to talk never touches any of this. ## Frames arrive sparsely StandIn drops a frame rather than queueing it when the socket is busy. This is **not** a video stream and must not be treated as one. The useful shape, and the one every provider plugin ends up with, is to keep the latest frame and look only when something asks: ```python theme={null} frame = session.latest_video_frame() # screen share wins over camera if frame is not None: answer = await describer.describe(frame, "What is on the slide?") ``` The server keeps the latest frame per source for you, so a plugin that only wants on-demand vision implements no callback at all. Pass `"camera"` or `"screenshare"` to be explicit; with no argument the screen share wins, because an agent asked to look is nearly always being asked about what is being *shown* rather than who is showing it. Take every frame instead, which is what [ambient vision](#ambient-vision) needs, by implementing the optional sixth method: ```python theme={null} class MyHandler: async def on_video_frame(self, frame) -> None: ... ``` `on_video_frame` runs on the receive path of a live call. Do a slow model call off the frame loop, exactly as you would in `on_caller_audio`. `on_video_frame` is deliberately not part of `CallHandler`, which is a runtime-checkable protocol: a sixth member there would make `isinstance(handler, CallHandler)` fail for every handler written before the vision lane existed, while the documented rule says each method is optional. The server duck-types it, so implementing it is enough. ## What a frame is | Attribute | What it is | | ------------------------------------ | ------------------------------------------------------------------------------------ | | `source` | `"camera"` or `"screenshare"`. | | `width`, `height` | Pixels, already downscaled by StandIn. | | `mime` | `image/jpeg`. | | `data_base64` | The image exactly as it arrived. | | `data` | The decoded bytes, for an API that uploads a file. | | `data_url` | A `data:` URL, which is what most vision models take. | | `participant_id`, `participant_name` | Who it belongs to. Best-effort, and **absent** for guest and anonymous participants. | An unusable frame is dropped rather than raised. The call is healthy, the caller is still talking, and one malformed image is not worth ending a conversation over. ### Who it came from, and whether it changed `VIDEO_SOURCES` is the pair `source` may hold, `("camera", "screenshare")`. A message naming anything else never becomes a `VideoFrame` at all, so you never have to defend against a third lane appearing. Four helpers turn a frame into something a model can be told about: | Function | What it gives you | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `frame_owner(frame)` | The participant name the wire carried, or `None`. | | `fallback_owner(source)` | `"a participant"` for a screen share, `"the caller"` for a camera. | | `frame_caption(owner)` | The sentence that goes beside the picture, built from that owner alone: `screen shared by a participant`, or `camera of Dana`. | | `frame_digest(data_base64)` | A 32 character fingerprint of the frame. | Attribution **degrades rather than vanishing**. Guest and anonymous participants arrive with no name, and `fallback_owner` gives those a generic label instead of nothing, because "a participant's screen" is worth more to a model than an unlabelled picture. Dropping the attribution instead would leave a model in a group call unable to say whose deck it is looking at. `FrameDescriber` composes the two for you, and so does ambient vision. The digest answers one question, "is this the same screen as last time?", without keeping the picture to compare against. It hashes the base64 rather than the decoded bytes: two encodes of an unchanged screen are byte-identical, so it is exact, and it costs no decode on a path that sees every frame. The keyframe store uses it to avoid filling itself with one frozen slide, and ambient vision latches on it. ## Giving a voice model eyes Most speech-to-speech providers hear but cannot see. `FrameDescriber` closes that gap: the frame goes to a vision model of your choosing and a sentence comes back. ```bash theme={null} export STANDIN_VISION_API_URL=https://api.openai.com/v1/chat/completions export STANDIN_VISION_MODEL=gpt-4o-mini export STANDIN_VISION_API_KEY=... ``` ```python theme={null} from standin import FrameDescriber describer = FrameDescriber.from_env() # None when it is not configured ``` Any OpenAI-compatible chat-completions endpoint that accepts images works, including one you run yourself. The frame is sent for inference and **not stored**, which is the difference between this and uploading it into a provider's own conversation history. This URL is deliberately **not** put through the SDK's fetch guard. It is yours, set by you in the environment, and a vision model on localhost is a normal way to run one. That is the opposite of an image URL a model chose, which is untrusted and does go through the guard. ## Showing the caller something ```python theme={null} await session.display_image(chart_png, "image/png", duration_ms=4000, caption="Q3 revenue") ``` The image is drawn on the bot's video tile for a few seconds, then the avatar returns. `mode` is `"fullscreen"` or `"overlay"` for a picture-in-picture inset. Bytes or an already-base64 string both work. An image that is too large, or a type the service will not draw, is refused here where the error names the problem, rather than letting the service close the socket in the middle of a call. ### Fetching an image a model chose An agent that can show a picture will sooner or later be handed a URL by its own model, and that model is steered by whoever is on the call: ```python theme={null} from standin.fetch import fetch_public_image from standin.vision import MAX_IMAGE_BYTES image, mime = await fetch_public_image(url, MAX_IMAGE_BYTES) ``` http and https only, no embedded credentials, and no host that resolves into private, loopback, link-local or reserved space. The address the socket actually connects to is re-checked, closing the window where a hostname resolves publicly for the validation and privately for the fetch. One redirect hop is followed, because image CDNs habitually redirect to the real asset, and the target goes through the whole guard again. ## The tool surface `VisionTools` is the layer a model reaches for, and it lives in the core because every provider wants the same things and none of them should write the guards again. Build one per call and route your provider's tools at it. | Method | What it does | | ------------- | ----------------------------------------------------------------------- | | `look` | Answer a question about what the caller is showing now. | | `look_back` | Answer about a frame the caller has already moved past. | | `show` | Put an image you already have on the bot's tile. | | `show_url` | Fetch an image the model chose, through the guard, and show it. | | `show_file` | Put a local document on the tile: an image, a PDF page, an Office page. | | `show_many` | Show several pictures in turn, paced, without blocking the model. | | `walkthrough` | Say and show several of those in order, pausing for each. | **None of them raise at a model.** Every one returns a sentence, because the caller is a tool result being read back to something that will say it out loud, and an exception there is a silent tool and a confused agent. Two guards travel with them. **A budget**, because a model that can look can look in a loop and each look is a paid inference over somebody's screen. It is a sliding window, so a long call is not punished for having been long, and a failed look is refunded rather than charged. Spending returns a token and refunding takes that token back: two tool calls overlap, and refunding "the most recent charge" would refund the wrong one. **A keyframe store**, so "what did that slide say?" can be answered about a slide that is already gone. It is bounded, and it only keeps anything **while the call is being recorded**: keeping a history of somebody's screen is a materially different promise from glancing at it once, and the recording is what told the caller their call is being kept. ### Fullscreen or beside your face `DISPLAY_MODES` is the pair: `("fullscreen", "overlay")`. Every showing method takes `display`, and the model chooses it **per picture** rather than once for the deployment, because an inset is unreadable for a dense screenshot or a page of a document, which is exactly when somebody says "show me". Hardcoding either mode is wrong for half of what an agent shows. `show_image` carries the choice as an enum in its schema, which a model obeys far more reliably than the same list written into a description. Anything else a model says, and `pip` and `full` are both things it will say, falls back to the default you configured. When you configured none, the field is left off the wire entirely and the service decides, rather than this SDK deciding for it. `normalize_display_mode(value, default=None)` is the single rule that decides, shared by both SDKs and every plugin. It trims and lowercases, returns the value only when it is one of the two, and returns `default` for everything else, including `None` and anything that is not a string at all. A value that is not a mode **falls back rather than being passed on**: it came from a model, and putting a nearly-right word on the wire turns a readable picture into a field the service has no meaning for. ```python theme={null} tools = VisionTools(session, describer=describer, default_display_mode="overlay") await tools.show_url(url, "Q3 revenue", display="fullscreen") ``` What the caller can actually see is on `tools.last_shown`, recorded only after a send returned. So "email me that" attaches what they saw rather than what was attempted. `last_shown` is a `ShownImage`: the `image` and `mime` exactly as they were sent, the `name` shown beside it, an `at_ms` stamp, and `as_base64()` for handing the same picture to something that wants a string. One slot, replaced each time, rather than a growing copy of everything shown on the call. The name comes from `display_image_name(path_or_url, mime)`. It takes the last path segment with any query and fragment cut off, and only when what is left already looks like a filename; anything else becomes `image.png` or `image.jpg` built from the type. `../../etc/passwd` is not a filename, and this string was chosen by a model and is about to be drawn on the caller's screen. ### Several pictures in a row ```python theme={null} await tools.show_many( [ShowItem(chart_png, "image/png"), ShowItem("https://example.com/table.png")], caption="Q3", hold_ms=4000, ) ``` The first picture is on the tile before the call returns, so the model can say "here it is" and be right. The rest are paced from a detached task. A model that waited out a ten-picture slideshow before speaking would leave the caller in silence for most of a minute. `MAX_SLIDESHOW_IMAGES` pictures at a time, which is ten, and the sentence that comes back says when there were more: a model asked for "the slides" can mean forty of them. `hold_ms` defaults to `SLIDESHOW_HOLD_MS`, four seconds, and whatever you or a model passes is clamped to between one and thirty seconds. Each non-final picture is held past the pacing gap, by `SLIDESHOW_OVERLAP_MS` (half a second, in `standin.vision_tools` rather than at the barrel), so the next one arrives before the last has expired and the tile never blanks between two of them. The last carries no duration at all, so what stays on screen is the service's own default. Each picture is also loaded inside the pacing loop rather than up front, so a slideshow of ten URLs does not fetch all ten before the first appears, and one that fails skips that picture rather than the rest. There is one tile, so starting a slideshow or a walkthrough stops whatever was already running on it. The one being replaced is woken rather than killed: a picture already being sent finishes being sent, and the new first picture goes up immediately instead of waiting out the old gap. ### Saying each one as it goes up `walkthrough` is the narrated version, and a `WalkthroughStep` is one beat of it: a line to `say`, and optionally an `image` with its `mime` and `caption` to put up once that line has been said. ```python theme={null} from standin import WalkthroughStep await tools.walkthrough( [ WalkthroughStep(say="First, last quarter.", image=q3_png, mime="image/png"), WalkthroughStep(say="And here is the forecast.", image=forecast_png, mime="image/png"), ], speak=self.say, interrupted=lambda: self.caller_is_talking, ) ``` `speak` is yours, because "the line has finished being said" is a thing only your provider knows, and a walkthrough that guesses talks over itself. `interrupted` is checked between beats, for the same reason: somebody who cuts in has stopped the tour, and only the plugin can tell that they did. A step whose line or picture fails stops the tour there and names the step, rather than narrating pictures nobody is seeing. ### Showing a web page There is no browser in this SDK and there will not be one. `show_page` is the seam: you supply the renderer, it supplies the guard. ```python theme={null} from standin import SHOW_PAGE_TOOL, CallTools, VisionTools vision = VisionTools(session, describer=describer) tools = CallTools(session, vision=vision) tools.register(SHOW_PAGE_TOOL, lambda p: vision.show_page(p["url"], render=my_renderer)) ``` `register` is on `CallTools`, not on `VisionTools`: one model sees one list of tools, so a tool of your own is added where the built-ins already are. The handler itself reaches straight into the vision tools. Your renderer takes a URL and returns bytes plus a mime type, never a path. A path would make this SDK read a file chosen downstream of whoever is on the call, which is not a thing it should be able to do. The URL goes through the same public-address guard as every other URL a model chose, and it goes through it **even when your renderer is a browser with private-network protection of its own**. That protection assumes whoever wrote the URL already has a shell on the machine. Here the URL was written by a model a stranger is steering, which is precisely the case the relaxation lets through. Your renderer gets `PAGE_RENDER_TIMEOUT_S`, forty five seconds, which `timeout_s` overrides per call. Past it the caller is told in words that the page did not load, counted in whole seconds, because that sentence is read out loud to the person waiting and nobody says "within 1 seconds". What does render stays up for `PAGE_DISPLAY_MS`, fifteen seconds, rather than the few a chart gets: a page is read rather than glanced at. `show_page` is deliberately not one of the built-in tools. The built-in list is declared unconditionally, so putting it there would tell every model on every deployment that it can show a web page, and then apologise to the caller on every call where no renderer exists. Register it when you have one. ### Showing a file `show_file` is off until you name the directories it may read from: ```bash theme={null} export STANDIN_SHOW_ROOTS=/srv/shared-docs # pip install "standin-sdk[render]" for PDF pages ``` The path reaching that function was chosen by a model that a caller is steering, so it is resolved before it is compared. A `../` and a symlink are both judged by where they actually land rather than by how they are spelled. Rendering a PDF or an Office document is optional. Without it the tool says which piece is missing rather than failing quietly. ## Ambient vision `look` answers when a model reaches for it, which is the right shape most of the time and has one blind spot: the model has to know there is something to look at. Somebody who shares a deck and says "what do you think?" has told it nothing it can act on. `AmbientVision` closes that by pushing what changed on screen into the conversation between turns. It is **off unless a plugin turns it on**, because it spends money on every scene change and not every deployment wants that. ```python theme={null} from standin import AmbientImage, AmbientVision, CallSession, FrameDescriber, VideoFrame, VisionTools class MyHandler: async def on_start(self, session: CallSession) -> None: self.tools = VisionTools(session, describer=FrameDescriber.from_env()) self.ambient = AmbientVision( session, self._push, enabled=True, budget=self.tools.budget ) async def on_video_frame(self, frame: VideoFrame) -> None: self.ambient.offer(frame) async def aclose(self, reason: str) -> None: await self.ambient.aclose() async def _push(self, image: AmbientImage) -> None: # Into the provider's context, without asking it to answer. await self.provider.add_image(image.data_url, image.caption) ``` `offer` is synchronous, never blocks and never raises: it runs on the receive path of a live call, so all it does is keep the frame and wake the pass that does the work. `flush()` asks for a pass now and is safe to call from anywhere. `aclose()` stops it permanently and forgets what it was holding. ### The sink is yours `AmbientSink` is the one thing you supply: `Callable[[AmbientImage], Awaitable[None]]`, a callable rather than a class, which is why the example above hands over a bound method and the name it carries is your own. What to DO with a picture stays in the plugin, because only the plugin knows how to hand one to its provider. Two rules on it, and both are load bearing. **It must not make the agent reply.** Ambient vision is context. An agent that answers every scene change talks over the person presenting, which is worse than not having looked. **It must raise when delivery fails.** A sink that swallows its own error latches a frame that never arrived, and the model never sees that screen again. `AmbientImage` is the frame with the decisions already made: `source`, `mime`, `data_base64`, `width`, `height` and `ts` as they arrived, the `owner` and the `caption` that says whose screen this is, and a `data_url` property for the providers that take one. ### What keeps it from being expensive or creepy **The recording gate is checked before a frame is even stored.** Streaming somebody's screen to a model is a materially different promise from glancing at it once, and the recording is what told them their call is being kept. Storing the frame and gating only the send would mean that turning the recording on surfaces something from before the caller was told. Set `require_recording=False` only where something else made that promise. **The change latch is the last frame actually delivered**, per source, not the last one seen. A screen nobody touched costs nothing, and a delivery that failed leaves the latch alone and gives its charge back, so the same screen is retried rather than skipped. `change_key` replaces how sameness is judged; by default it is `frame_digest`. **The reserve stops the ambient lane spending the budget the caller's own request needs.** `VisionBudget.reserve` is a quarter of the window and never less than two, and the ambient lane is refused once the window is down to it, while an explicit `look` can still spend. A refused ambient push stops the whole pass rather than trying the next source, which would only spend the same exhausted budget. Hand `AmbientVision` the same `VisionBudget` your `VisionTools` holds, as the example above does, or that reserve protects nothing: given no budget it builds its own at `DEFAULT_AMBIENT_MAX_PER_MINUTE`, thirty a minute, and the two lanes are then capped independently instead of sharing one window. Handing it a budget with no ceiling at all, while enabled, logs a warning: every scene change is then charged. ### Pacing, and holding what cannot go yet `AMBIENT_SOURCE_ORDER` is `("screenshare", "camera")`. When both changed, the screen share goes first: somebody presenting is nearly always talking about the screen rather than about their face. `AMBIENT_BACKSTOP_MS`, six seconds, is how often it looks again when no frame arrived at all. A screen share can go quiet without ending, and the last thing on it is still what is being discussed. The timer is armed by the first frame that gets past the gate, so a call with no video never starts one. `sink_ready` covers a provider whose socket comes up after the call does. While it answers no, images are charged, latched and held rather than dropped, and what was held goes out oldest first once it answers yes, so the model sees the screen change in the order it happened. The queue is bounded at `MAX_QUEUED_AMBIENT_IMAGES`, six: a sink that never comes up would otherwise hold the whole call's video, and the oldest is what goes, because what is on screen now is worth more than what was. A held image that fails on its way out is dropped rather than requeued, or a dead sink grows the queue for ever. `on_delivered` is called with each image that actually landed, which is what a meeting recap hooks so the minutes can say what was on screen. `delivered` and `queued` count those two things for a health check. ## The avatar StandIn renders the avatar tile, and your worker steers it with two additive hints: the emotion the face wears, and the viseme timeline that makes the mouth match the words. Those, and putting your agent's own video on the tile in place of the rendered avatar, are on their own page: [The avatar](/python-sdk/avatar). # Turn-taking Source: https://docs.komaa.com/python-sdk/voice VoiceLane, utterance segmentation, WAV handling and paced playback for an agent that is not a realtime model, in the StandIn Python SDK. A realtime speech-to-speech provider is handed the caller's audio and hands audio back, and the turn-taking is theirs. Everything else is not like that. A transcription service wants one utterance at a time. A language model wants text. A text-to-speech engine hands back a whole buffer that somebody has to feed out at the rate a call consumes it. That shape is the same whichever three services you pick. A realtime plugin needs none of this and pays nothing for it. `standin.voice` is a module you do not import. ## One utterance per phrase A Microsoft Teams call delivers audio continuously: silence is still frames, fifty a second. `UtteranceSegmenter` is the gate between that and a service that wants a phrase. ```python theme={null} from standin import UtteranceSegmenter segmenter = UtteranceSegmenter() async def on_caller_audio(self, pcm): utterance = segmenter.feed(pcm) if utterance is not None: text = await transcribe(utterance) ``` A loudness floor and four bounds, and each exists because of a specific failure. Every default is exported, so a plugin that wants to nudge one can say which one it changed. | Argument | Default | Why | | ------------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `speech_rms` | `DEFAULT_SPEECH_RMS`, `0.02` | The loudness floor that decides what counts as speech at all, on the same 0.0 to 1.0 scale [`pcm16_rms`](/python-sdk/audio#pcm16-rms) returns. The knob most likely to need tuning on a noisy line: too high and a quiet caller is never heard, too low and the room opens the gate. | | `silence_ms` | `DEFAULT_SILENCE_MS`, `800` | A pause inside a sentence is not the end of one. Too short and the agent interrupts a thinking caller; too long and it feels slow. | | `preroll_ms` | `DEFAULT_PREROLL_MS`, `240` | The syllable that trips the gate is part of the word. Without it every utterance begins mid-consonant and the transcript loses the first word of most sentences. | | `min_utterance_ms` | `DEFAULT_MIN_UTTERANCE_MS`, `120` | A cough is not a turn. Sending one costs a request and returns nothing worth answering. Low on purpose: a single short word is 150 ms of voice and has to survive, while a click is a frame or two. | | `max_utterance_ms` | `DEFAULT_MAX_UTTERANCE_MS`, `30000` | A stuck-open microphone or a television in the room never goes quiet, and without a cap one utterance grows for the whole call. | `min_utterance_ms` is measured on the **loud part alone**, from the frame that opened the gate to the last loud frame. Measuring the whole buffer would count the pre-roll and the trailing silence, which together are over a second at these defaults, so the floor could never fire and every click would reach the transcriber. `segmenter.speaking` is true while the caller is mid-utterance. `flush()` takes what is held mid-utterance, for teardown. `reset()` abandons it, for a barge-in. ## The WAV a speech engine hands back It is rarely the one you wanted: 32-bit float, 44.1 kHz, stereo, or wrapped in `WAVE_FORMAT_EXTENSIBLE`, which ffmpeg emits even for plain PCM. Every one of those plays as noise on a call unconverted, and the header does not make the failure obvious. ```python theme={null} from standin import decode_wav, encode_wav pcm = decode_wav(await tts.speak(text)) # PCM16 mono at the call's rate ``` Chunks are walked rather than assumed at a fixed offset, because a real encoder puts `LIST` and `fact` chunks before the data and a fixed offset reads them as samples. Stereo is averaged rather than halved, so whoever is on the right channel is not lost. Floats outside the range are clamped, because wrapping is the loudest possible click. Eight-bit WAV is read as unsigned and centred on 128, which read as signed is a square wave of noise. `decode_wav` raises `ValueError` on anything it cannot read, naming what it found. It is the one helper on this page that does raise, because a buffer that is not a WAV at all is a configuration mistake rather than a glitch, and playing it would be noise on a live call. `encode_wav(pcm, sample_rate_hz=SAMPLE_RATE_HZ)` goes the other way, for a transcription service that will not take raw PCM. Pass the rate when you are wrapping something that is not at the call's. ## Playing it back A text-to-speech engine returns a whole utterance at once. A call takes 20 milliseconds every 20 milliseconds. Sending the buffer in one go hands the service seconds of audio it must queue, and that queue is what makes a barge-in arrive too late to matter: the caller interrupts, the model stops, and the bot keeps talking for the length of what was already sent. ```python theme={null} from standin import PacedPlayback playback = PacedPlayback(session.send_audio) result = await playback.say(pcm) if result.interrupted: ... ``` The pacing is on an **absolute clock**. Sleeping 20 ms per frame accumulates every scheduling delay, and a minute of speech ends seconds behind where it should be. This one sleeps until the next frame is due, so a late frame is followed by a short sleep rather than a full one. `playing` is true while a buffer is going out. `cancel()` stops it, and is safe to call from a receive loop. Turns are serialised, so two cannot interleave into one stream the caller hears as both at once. The result is a `Playback`: `sent_ms` against `total_ms`, which is the difference between "I told them" and "I started to", plus `interrupted` and the `complete` property that is its inverse. `complete` exists in Python only. The TypeScript `Playback` carries `sentMs`, `totalMs` and `interrupted` and nothing else, so a check ported between the languages has to be written as `!interrupted` there. ## One object that runs the whole turn The three pieces above are the parts. `VoiceLane` is the assembly: one per call, fed the caller's frames, responsible for everything between a frame arriving and an answer being heard. ```python theme={null} from standin import CallSession, VoiceLane class MyHandler: async def on_start(self, session: CallSession) -> None: self.lane = VoiceLane( session, transcribe=my_stt, answer=my_agent, synthesize=my_tts, ) async def on_caller_audio(self, pcm: bytes) -> None: await self.lane.feed(pcm) async def aclose(self, reason: str) -> None: await self.lane.aclose() ``` `feed` never blocks and never raises. It runs on the receive path of a live call, so the turn it starts is detached: awaiting a model there stops frames arriving, and a lane that has stopped hearing the caller cannot notice an interruption. ### The three callables | You supply | It is given | It returns | | ------------ | ---------------------------------------- | --------------------------------------------------------------------- | | `transcribe` | one whole utterance, PCM16, 16 kHz, mono | the words, as `str`. Empty or whitespace means nothing was said | | `answer` | that transcript | the answer as `str`, or an async iterator of pieces | | `synthesize` | one piece of answer text | speech as PCM16, 16 kHz, mono `bytes`, or an async iterator of chunks | Each of those contracts is there for a reason. `transcribe` is handed a phrase, not frames. Working out where the caller stopped is the segmenter's job above, and it is the one part of this a transcription service will not do for you. Its empty return is a signal rather than a failure. It is how you say "there were no words in that", and the lane acts on it. `answer` returning a plain string is the simple case. Returning an async iterator is what gets the first sentence out before the last one exists. The lane tells the two apart at runtime, so a callable is free to return either without a flag to configure. `synthesize` returns PCM at the call's rate, because what comes back goes straight to paced playback. Nothing in the lane converts for you, and the two cases are different: * **A WAV goes through `decode_wav` and nothing else.** It handles the sample rate, the channel count and the sample format in one step, and returns PCM16 mono at the call's rate already. * **Raw PCM at another rate goes through `resample_pcm16`.** That is the case where an engine hands back bare samples at 22.05 or 24 kHz. Do not run both over a WAV. `decode_wav` has already resampled, so resampling its output again as if it were still 24 kHz produces a pitch-shifted buffer two thirds the length it should be, and the header gives no hint that anything went wrong. ### The awkward parts **One turn at a time.** A new utterance cancels the turn in flight before it starts its own. An agent asked two questions at once answers neither well, and both answers would be spoken over each other. The superseded turn stops where it stands, and whatever it had not yet spoken is never spoken. Whichever of your three callables was running is cancelled at its next `await`, so release anything it holds open in a `finally`. **Barge-in lands when the interruption starts, not when it ends.** The moment the caller's voice opens over the top of an answer, the buffered audio is dropped and StandIn is told to drop what it still holds. Waiting for that utterance to finish would spend the whole of it talking at somebody who has stopped listening, which is the difference between a call that feels alive and one that does not. `barge_in()` is public, for an interruption the caller's audio does not show: ```python theme={null} async def on_goodbye(self, text: str) -> None: await self.lane.barge_in() await self.lane.say(text) ``` **A silence is not a turn.** An utterance that transcribes to nothing ends there. The agent is never asked and nothing is spoken. The segmenter opens on any loud frame, so a cough, a door or a second of traffic reaches the transcriber, and waking the agent for every one of them is a bill and a caller being answered at random. Note the difference from the case below: an empty transcript is silent, a transcriber that fails says so out loud. ### Nothing raises into the call A step that fails is logged, and the caller hears a sentence. | When | What is spoken | | ------------------- | --------------------------------------------------------------------- | | `transcribe` raises | `TROUBLE_HEARING`, "Sorry, I did not catch that." | | `answer` raises | `TROUBLE_ANSWERING`, "Sorry, I am having trouble answering just now." | | `synthesize` raises | `TROUBLE_SPEAKING`, "Sorry, I am having trouble speaking just now." | Silence is the one thing a caller cannot interpret. Somebody on a phone call cannot tell a broken transcriber from an agent that is thinking, so they wait, and then they hang up. `TROUBLE_SPEAKING` goes back through your own `synthesize`, once. The retry is guarded, because an engine that is down cannot say the sentence about being down, and the lane goes quiet rather than looping on it. All three are exported from `standin`, so a test can assert on the sentence itself. ### A long answer, spoken as it is written Return an async iterator from `answer` and each piece is synthesized and played as it arrives. ```python theme={null} async def my_agent(heard: str): async for sentence in llm.stream(heard): yield sentence ``` The caller hears the beginning of a long answer while the rest is still being written, which is the whole point: a model that takes four seconds to finish a paragraph leaves four seconds of silence otherwise. Yield whole sentences. Each piece is one `synthesize` call and one spoken buffer, so a token at a time is a synthesis request per word and speech chopped into syllables. Pieces that are blank are skipped. An interruption stops the loop, and what was never reached is never synthesized and never spoken. ### Saying a line nobody asked for A greeting, a handover, something that arrived from outside the call. ```python theme={null} async def on_start(self, session: CallSession) -> None: self.lane = VoiceLane(session, my_stt, my_agent, my_tts) await self.lane.say("Thanks for taking the call.") ``` `say` skips `answer` and goes straight to synthesis and paced playback, so the line can be interrupted like any other answer, and it queues behind whatever is already speaking rather than overlapping it. It returns the `VoiceTurn`, which is how you find out whether it was heard to the end. It does not supersede a turn in flight, so call `barge_in()` first when the line has to come before what is being said. ### Closing the lane `aclose()` drops the buffered audio, resets the segmenter, cancels the turn in flight and waits for it to unwind. Call it from your handler's `aclose`, once. After that, `feed` is a no-op rather than an error. Frames keep arriving for a moment after teardown begins, and the end of a call is the worst place to start raising from the receive path. ### What each turn leaves behind `VoiceTurn` is the record of one exchange: `heard`, `said`, `interrupted` and `error`. `interrupted` is not a failure. It is the most common way a real conversation goes. Pass `on_turn` to receive one per caller turn the agent answered, and you have a transcript for free: ```python theme={null} from standin import VoiceLane, VoiceTurn def remember(turn: VoiceTurn) -> None: transcript.add(caller_name, turn.heard) transcript.add("Assistant", turn.said, role="assistant") lane = VoiceLane(session, my_stt, my_agent, my_tts, on_turn=remember) ``` It is a plain function, not a coroutine, and it is called inline at the end of a turn, so keep it short. An exception it raises is swallowed: a plugin's own bookkeeping must not end a call. A turn the transcriber or the agent failed is spoken but not reported, so this is a record of the conversation rather than a count of what went wrong. An answer that came back empty is not reported either, because nothing was said. Nor is a line from `say`: it hands you its `VoiceTurn` as the return value instead, so a greeting is missing from a transcript built only from `on_turn`. A synthesis failure is the one that does reach it, with the trouble sentence as `said` and the engine's message in `error`. Check `error` before filing a turn as something the agent meant to say. Three more things on the constructor and the object. `segmenter` takes an `UtteranceSegmenter` you tuned yourself, with the floor and the four bounds above. `speaking` is true while audio is going out, and `busy` covers the whole turn, including the model's own thinking. `turn` is the in-flight `asyncio.Task`, or `None`, which is how a test or a teardown lets one finish. A superseded turn is **cancelled** here, at its next `await`. The TypeScript twin cannot do that, because a promise cannot be cancelled, so it retires the older turn by generation number and lets it run to completion: a request already in flight there is still paid for and still has to release its own resources in a `finally`. Both are correct for their language, and a plugin ported between them has to know which it is getting. ## What this page does not cover Two more shared lanes sit next to this one and are worth knowing exist before you write them yourself. * **The echo guard.** On a speakerphone the agent's own voice comes back in, the model's voice detection hears it, and the agent answers itself in a loop. `EchoGuard` is the thing that stops it, on a playout clock rather than a wall clock. See [Realtime providers](/python-sdk/realtime-providers#the-agent-answering-itself). * **The meeting gate.** In a group call the prior question is whether to answer at all. `GroupGate` decides, and is inert on a one-to-one call. See [Group calls](/python-sdk/group-calls). ## Next `pcm16_rms`, the resampler and the frame aligner these bounds are measured with. Giving the agent behind this lane something to do besides talk. # Quickstart Source: https://docs.komaa.com/quickstart Answer a real Microsoft Teams call with the StandIn SDK: install the package, expose one port, register the agent voice URL, and hear yourself back. This gets you to a live Microsoft Teams call answered by an echo. Nothing about your agent is involved yet, which is the point: if the echo answers, your secret, your tunnel and your StandIn identity are all correct, and every later failure is your agent's. ## Prerequisites * A **StandIn identity** at [standin.komaa.com](https://standin.komaa.com). This is the hosted service that joins the Microsoft Teams call and dials your worker. * A **Microsoft Teams identity** for the bot. This is the choice that decides how much Azure work you do: * **Install StandIn AI Teammate from the [Microsoft Teams Store](https://teams.microsoft.com/l/app/efcbbdea-3d93-47f7-b106-f2c33126d0b5)** ([Microsoft Marketplace listing](https://marketplace.microsoft.com/en-us/product/office/WA200012046)). No Azure app registration, no App ID, no client secret. Add the app, type `signin`, and connect your agent from the StandIn portal. A Microsoft 365 admin approves calling once for the organization. * **Bring your own Microsoft Teams bot** (an Azure Entra app) and connect it to StandIn. See [Microsoft Teams setup](/teams/overview). Choose this when the bot must carry your own name and live entirely in your tenant. [Connection modes](/teams/connection-modes) compares the two in full. * **Python 3.10 or newer**, or **Node.js 20 or newer**, depending on which SDK you use. The OpenClaw plugin follows its host and wants Node 22.19, but that travels with the plugin and nothing else in the TypeScript SDK asks for it. * A way to publish one local port over HTTPS: a tunnel such as Tailscale Funnel, or an ingress. [Expose your agent](/expose) covers both. No Azure bot of your own? [Add StandIn AI Teammate to Microsoft Teams](https://teams.microsoft.com/l/app/efcbbdea-3d93-47f7-b106-f2c33126d0b5) and connect it as the [StandIn AI Teammate app](/teams/connection-modes#standin-ai-teammate-app). There is nothing to register in Azure. ## 1. Run the echo The echo plugin sends the caller's voice back. Run one of these, not both. ```bash Python theme={null} python3 -m venv .venv source .venv/bin/activate pip install standin-sdk export STANDIN_SECRET="your-StandIn-connection-secret" export STANDIN_HOST="127.0.0.1" python -m standin.plugins.echo ``` ```bash TypeScript theme={null} npm install @komaa/standin-sdk export STANDIN_SECRET="your-StandIn-connection-secret" export STANDIN_HOST="127.0.0.1" npx standin-echo ``` `STANDIN_SECRET` is the connection secret from your StandIn identity, and it is what arms the listener: without it the SDK refuses to start rather than serving an unauthenticated socket. `STANDIN_HOST=127.0.0.1` keeps the listener on loopback so only your tunnel reaches it. The SDK's own default is `0.0.0.0`, which is right for a container behind an ingress and wrong for a laptop. The listener now sits on port `9442` at path `/msteams/calling`. **Prove it before you expose anything.** The SDK ships an install check that rings your own handler on loopback: it binds an ephemeral listener, speaks the real call wire to it, streams a few frames and reports whether audio came back, with no tunnel, no provider bill and no Microsoft tenant involved. It is the fastest answer to "is my install working", and it is worth running before the first real call rather than after it: [Checking the install](/python-sdk/checking-the-install) in Python, [Checking the install](/typescript-sdk/checking-the-install) in TypeScript. ## 2. Publish the port StandIn dials you from the internet, so a loopback-only listener is invisible to it. Publish port `9442` at `/msteams/calling`, and keep the path in the proxy target or the request reaches your worker stripped. [Expose your agent](/expose#mount-the-call-path) carries the one copy of the mount command, plus the ngrok, cloudflared and devtunnel equivalents. Confirm the mount, and probe it, before you register anything. A registered mount is not a working mount, and a probe sent without `--http1.1` never arrives as an upgrade at all, so whatever it answers proves nothing: [Probe it by hand](/expose#probe-it-by-hand). ## 3. Register the agent voice URL In your StandIn identity, set the **agent voice URL** to the public address of that path: ``` wss:///msteams/calling ``` Paste a bare host and StandIn appends the default path. Paste a full URL and it is respected as given. The secret you registered must **byte-match** `STANDIN_SECRET`: a mismatch fails the HMAC handshake silently, which is the single most common first-call failure. ## 4. Call it Call your StandIn identity from Microsoft Teams and talk. You should hear yourself back. If nothing happens, work through [Troubleshooting](/troubleshooting) top to bottom: secret, reachability, address. Do not start by suspecting the SDK. ## Configuration The SDK reads its settings from the environment and ships no config file of its own. A plugin that runs **inside another runtime** is configured by that runtime instead, which is why the OpenClaw plugin takes its secret and its port from OpenClaw's own config block rather than from any of the variables below. These are the variables this page uses: | Variable | Default | What it does | | --------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `STANDIN_SECRET` | none, **required** | The connection secret. Signs the handshake on both lanes. Without it the SDK raises rather than starting. | | `STANDIN_PORT` | `9442` | Port the call listener binds. | | `STANDIN_HOST` | `0.0.0.0` | Bind address. Set `127.0.0.1` when a tunnel is in front. | | `STANDIN_WS_PATH` | `/msteams/calling` | Path StandIn dials. StandIn appends `/{callId}` per call. | | `STANDIN_CHAT_URL` | StandIn's chat channel | Only for the outbound chat lane. Override it when StandIn gives you a different chat address. | | `STANDIN_CHAT_SECRET` | falls back to `STANDIN_SECRET` | Only for the chat lane. A StandIn AI Teammate app connection may issue a second key for chat; set it and the chat lane signs with that one instead. | Those are the ones the echo and the chat lane need, not the whole list: placing a call, vision, sending a picture and the on-disk state directory each read their own, and the two SDKs do not read an identical set. Every `STANDIN_` variable each one reads is tabulated in one place, so a deployment is not configured out of nine pages: [Configuration](/python-sdk/configuration) in Python, [Configuration](/typescript-sdk/configuration) in TypeScript. ## Next The handler contract, the audio helpers, the chat lane, and the LiveKit and Hermes plugins. The same contract in TypeScript, and the OpenClaw plugin. What the SDK owns, what you own, and what crosses the wire. The Azure bot, the app package, and the admin consent calling needs. # Get the Microsoft Teams app package Source: https://docs.komaa.com/teams/app-package Download the generated Microsoft Teams app package (manifest zip) from StandIn, or build it yourself from the example manifest. Part 2 of the [Microsoft Teams setup](/teams/overview). The app package is what makes your bot appear (and be callable) in Microsoft Teams: a zip with a `manifest.json` and two icons. Every SDK and plugin uses the **same package**: it describes the bot identity, not the agent behind it. ## Download it from StandIn (recommended) Your StandIn dashboard generates a **per-identity** app package with your `botId` and valid domains already filled in. Open your identity and click **Download Microsoft Teams app package (.zip)**, then continue to [Upload to Microsoft Teams](/teams/publish). The generated package contains **no secrets** - a Microsoft Teams manifest is public metadata (IDs, names, icons). Anyone who obtains it can still only reach the bot your tenant consented to. If you use the generated package you can skip straight to [uploading](/teams/publish). The rest of this page is for building or auditing the package yourself. ## Example manifest The example uses obvious placeholders - **do not publish with these values.** ```json manifest.json theme={null} { "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.23/MicrosoftTeams.schema.json", "manifestVersion": "1.23", "version": "1.0.0", "id": "00000000-0000-0000-0000-000000000000", "name": { "short": "StandIn", "full": "StandIn - Microsoft Teams Voice & Video AI" }, "developer": { "name": "Komaa", "websiteUrl": "https://standin.komaa.com", "privacyUrl": "https://standin.komaa.com/privacy", "termsOfUseUrl": "https://standin.komaa.com/terms" }, "description": { "short": "Voice & video AI for Microsoft Teams - converse, see the caller, appear as an avatar.", "full": "StandIn turns your Microsoft Teams bot into a Conversational Video Interface (CVI): callers talk to your AI in real time, share camera and screen, and see a lip-synced avatar on the bot tile. Subscribe at standin.komaa.com, connect your own Azure bot, and pair it with any of the open-source agent bridges." }, "icons": { "outline": "outline.png", "color": "color.png" }, "accentColor": "#16A34A", "bots": [ { "botId": "11111111-1111-1111-1111-111111111111", "scopes": ["personal", "team", "groupChat"], "isNotificationOnly": false, "supportsCalling": true, "supportsVideo": true, "supportsFiles": true } ], "validDomains": [], "webApplicationInfo": { "id": "11111111-1111-1111-1111-111111111111" }, "authorization": { "permissions": { "resourceSpecific": [ { "name": "ChannelMessage.Read.Group", "type": "Application" }, { "name": "ChannelMessage.Send.Group", "type": "Application" }, { "name": "Member.Read.Group", "type": "Application" }, { "name": "Owner.Read.Group", "type": "Application" }, { "name": "ChannelSettings.Read.Group", "type": "Application" }, { "name": "TeamMember.Read.Group", "type": "Application" }, { "name": "TeamSettings.Read.Group", "type": "Application" }, { "name": "ChatMessage.Read.Chat", "type": "Application" } ] } } } ``` Key bot fields: **`supportsCalling: true`** (required for voice) and **`supportsVideo: true`** (required for the CVI avatar tile). The `resourceSpecific` block is **per-team consent (RSC)** - granted when the app is installed in a team; it does **not** replace the tenant-wide [Graph permissions](/teams/azure-bot#8-grant-graph-permissions). Official reference: [Microsoft Teams app manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema). ## Placeholders | Field | Example value | Replace with | | ----------------------- | ------------------------- | ---------------------------------------------------------------------------------- | | `id` | `00000000-…-000000000000` | A **new random GUID** for the app package (unique per listing; not the bot ID). | | `bots[].botId` | `11111111-…-111111111111` | Your **Entra application (client) ID** (same as the Azure Bot's `MicrosoftAppId`). | | `webApplicationInfo.id` | same as `botId` | **Must equal `botId`** for a standard single-app bot. | | `validDomains` | `[]` | Your **StandIn host**, e.g. `["standin.komaa.com"]`. | Generate GUIDs with `uuidgen` (macOS/Linux), `[guid]::NewGuid()` (PowerShell), or [uuidgenerator.net](https://www.uuidgenerator.net/). ## Icons The package needs a **color icon** (192×192 px, `color.png`) and an **outline icon** (32×32 px, transparent, `outline.png`). In the [Developer Portal](https://dev.teams.microsoft.com/) they upload under **Branding**; in a manual zip they sit next to the manifest. ## Packaging it manually Put the three files at the **root** of the archive (Microsoft Teams rejects a package where they sit in a subfolder): ``` msteams.zip ├── manifest.json ├── color.png └── outline.png ``` Zip the **files themselves**, not the containing folder - `msteams/manifest.json` inside the zip will be rejected. Bump `version` whenever you republish the package. Official reference: [Create a Microsoft Teams app package](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/build-and-test/apps-package). Next: [Upload to Microsoft Teams and go live](/teams/publish). # Create the Azure bot Source: https://docs.komaa.com/teams/azure-bot Register a Microsoft Entra app, create the Azure Bot resource, enable Microsoft Teams calling, and grant the Graph permissions StandIn needs. Part 1 of the [Microsoft Teams setup](/teams/overview). At the end of this page you will have a bot identity (client ID + tenant ID + client secret) wired to StandIn's endpoints, with the Graph permissions consented. All screenshots are from the Azure portal; the values shown are placeholders. ## 1. Register a Microsoft Entra app In the [Azure portal](https://portal.azure.com), search for **App registrations**: Azure portal search bar with App registrations typed in and the App registrations service shown in the results Select **New registration**: * **Name**: anything you like (users never see it; the Microsoft Teams app name comes from the manifest). * **Supported account types**: **Single tenant only** is the right choice for a bot that serves your own organization. * **Redirect URI**: leave empty. Register an application form in the Azure portal with a name filled in and Single tenant only selected Official reference: [Register an application with the Microsoft identity platform](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app). ## 2. Record the IDs On the app's **Overview** page, copy two values - you will need them repeatedly: * **Application (client) ID** - this is your **`botId`** (and the `MicrosoftAppId` of the Azure Bot). * **Directory (tenant) ID** - your tenant. Entra app registration overview page highlighting the client ID and tenant ID fields ## 3. Create a client secret Under **Certificates & secrets**, select **New client secret** and copy the secret **Value** immediately - it is shown only once. StandIn uses this credential (App ID + secret + tenant) to authenticate as your bot; you provide it in the StandIn dashboard when you register the identity. Treat the client secret like a password: store it in a secret manager, never commit it, and set a reminder before its expiry date - calls stop when it lapses. ## 4. Create the Azure Bot resource Search the portal for **Bot** and open **Bot Services**: Azure portal search bar with Bot typed in and the Bot Services service shown in the results Create an **Azure Bot** and fill in the basics: * **Bot handle / subscription / resource group**: your choice. * **Data residency**: Global is typical. * **Pricing tier**: the free **F0** tier is fine (Standard channels like Microsoft Teams are free). * **Type of App**: **Single Tenant**, matching the app registration. Create an Azure Bot basics form in the Azure portal Under **Microsoft App ID**, choose **Use existing app registration** and paste the **App ID** (client ID) and **App tenant ID** from step 2, then **Review + create**. Create an Azure Bot form showing Use existing app registration selected with App ID and tenant ID fields Once deployment finishes, the resource's **Overview** shows the essentials - pricing tier, location, and (after step 7) the messaging endpoint: Azure Bot resource overview page showing resource group, subscription, pricing tier, and messaging endpoint Official reference: [Create an Azure Bot resource](https://learn.microsoft.com/en-us/azure/bot-service/abs-quickstart). ## 5. Enable the Microsoft Teams channel Open the new Azure Bot resource, go to **Settings → Channels**, and select **Microsoft Teams**. Accept the terms and apply; the channel appears under connected channels as **Healthy**. Azure Bot Channels page showing the Microsoft Teams channel connected with a healthy status Opening the channel shows three tabs - **Messaging**, **Calling**, and **Publish**. Messaging is on by default (**Microsoft Teams Commercial** is right for most tenants); the next step configures Calling. Microsoft Teams channel Messaging tab showing Microsoft Teams Commercial selected ## 6. Enable calling and set the webhook Still in the Microsoft Teams channel, open the **Calling** tab: 1. Check **Enable calling**. 2. Set **Webhook (for calling)** to the **calling webhook URL from your StandIn dashboard** (shown on your identity after you register it). 3. **Apply**. Microsoft Teams channel Calling tab with Enable calling checked and the webhook field highlighted Official references: [Registering a calling bot](https://microsoftgraph.github.io/microsoft-graph-comms-samples/docs/articles/calls/register-calling-bot.html) (Microsoft Graph communications samples - covers this whole page end to end) and [Register a calling bot for Microsoft Teams](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/calls-and-meetings/registering-calling-bot). ## 7. Set the messaging endpoint (optional, for chat) Voice works without this step. If you also want **Microsoft Teams chat**, go to **Settings → Configuration** and set **Messaging endpoint** to the URL from your StandIn dashboard. Azure Bot Configuration page with the messaging endpoint field highlighted This messaging endpoint matters only if you serve Microsoft Teams chat yourself. On a bring-your-own-bot identity you also set an **Agent chat URL** in the StandIn dashboard: the HTTPS URL the portal shows. Until then, chat messages get a "chat endpoint not configured" reply. That endpoint is yours, not the SDK's. The SDK's chat lane is dialed **out** by `ChatChannel`, needs no URL registered and no port exposed, and is available on the StandIn AI Teammate app. See [Expose your agent](/expose) and [Connection modes](/teams/connection-modes#endpoints). **Two different sets of endpoints, and mixing them up breaks the setup.** The URLs on *this* page - the calling webhook in step 6 and the messaging endpoint in step 7 - are StandIn URLs (`https://.standin.komaa.com/api/calling` and `/api/messages`), taken from your dashboard and pasted into Azure. The worker URL you publish (`/msteams/calling`) goes in the StandIn dashboard, never in the Azure portal. ## 8. Grant Graph permissions Back on the **app registration** (the same Entra app as `botId`): **API permissions → Add a permission → Microsoft Graph → Application permissions**, add the list below, then select **Grant admin consent** for your tenant (requires an admin). Azure portal API permissions blade with Add a permission opening the Microsoft Graph API panel This is the canonical list. It applies to every SDK and plugin alike:
Permission Purpose
`Calls.JoinGroupCall.All`answer / join Microsoft Teams calls and meetings
`Calls.JoinGroupCallAsGuest.All`join meetings as a guest participant
`Calls.AccessMedia.All`access real-time Microsoft Teams call audio/video media
`Calls.Initiate.All`outbound 1:1 "call me back" (skip if unused)
`Calls.InitiateGroupCall.All`outbound group "call me back" (skip if unused)
`Chat.Read.All`resolve chat / thread ids and read message context
`ChatMessage.Read.Chat`read messages in chats the bot is installed in
`Sites.ReadWrite.All`upload files / minutes to SharePoint (OneDrive) for chat attachments (optional; not needed on Hermes - its minutes file card rides the Bot Framework attachment contract with bot credentials only)
After consent, every row shows a green **Granted** status: API permissions page listing the Calls application permissions, each with a green Granted status Official references: [Registering a calling bot](https://microsoftgraph.github.io/microsoft-graph-comms-samples/docs/articles/calls/register-calling-bot.html) (lists the `Calls.*` permissions), [Grant tenant-wide admin consent](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent), and the [cloud communications API overview](https://learn.microsoft.com/en-us/graph/cloud-communications-get-started). ## Checkpoint You should now have: * [x] **Client ID** (`botId`), **tenant ID**, and a **client secret** * [x] An **Azure Bot** with the **Microsoft Teams channel** enabled * [x] **Calling enabled** and the webhook pointed at StandIn * [x] (Optional) **Messaging endpoint** set for chat * [x] Graph permissions **admin-consented** Next: [Get the Microsoft Teams app package](/teams/app-package). # Connection modes Source: https://docs.komaa.com/teams/connection-modes Two ways to put your agent in Microsoft Teams: the StandIn AI Teammate app from the Microsoft Teams Store, or your own Azure bot. There are two ways to connect an agent to Microsoft Teams. They differ in **who owns the Microsoft Teams bot**, and that single difference decides how much Azure work you do. | | StandIn AI Teammate app | Bring your own Azure bot | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------- | | Who owns the bot | StandIn | You | | Azure app registration | none | you create it | | App ID / client secret | none | you manage them | | Install | add **StandIn AI Teammate** from the [Microsoft Teams Store](https://teams.microsoft.com/l/app/efcbbdea-3d93-47f7-b106-f2c33126d0b5) | upload your own app package | | Identity in Microsoft Teams | appears as StandIn | appears as your own bot | | Best for | getting working quickly | full identity custody | Both modes connect **your own agent**: StandIn is the bridge, never the brain. And both need the same one-time admin consent before calling works - chat works without it. ## StandIn AI Teammate app StandIn publishes and operates one Microsoft Teams bot; your Microsoft organization is routed to your agent by a connection you create in the StandIn portal. 1. In Microsoft Teams, add **StandIn AI Teammate** from the Store: [open it in Microsoft Teams](https://teams.microsoft.com/l/app/efcbbdea-3d93-47f7-b106-f2c33126d0b5), or find it in [Microsoft Marketplace](https://marketplace.microsoft.com/en-us/product/office/WA200012046). 2. Type `signin` to the bot and sign in with your StandIn account. That link is how StandIn learns which Microsoft organization to connect - you do not type a tenant id anywhere. 3. In the StandIn portal, deploy an identity and choose **Use the StandIn AI Teammate app**. Paste your agent's address; the portal gives you back the secrets. 4. A Microsoft 365 administrator approves calling once for the organization. Chat works before this; calling switches on when the approval lands. You get a **connection secret**, and on this SDK it is one environment variable: ```bash theme={null} export STANDIN_SECRET='' ``` A StandIn AI Teammate app connection may issue a **second key for chat**. That is deliberate, not an oversight: each lane signs its own handshake, so two keys mean a key that can open one lane cannot open the other. If you are given one, export it as `STANDIN_CHAT_SECRET` and the chat lane signs with that instead. `ChatChannel` reads `STANDIN_CHAT_SECRET` first and falls back to `STANDIN_SECRET`, so when a single key covers both lanes there is nothing extra to set. The StandIn AI Teammate app is also what unlocks the SDK's chat lane. `ChatChannel` dials **out** to StandIn with whichever of those two keys it found, so chat needs no URL registered, no open port and no tunnel rule, and your agent never holds a Bot Framework credential. It needs no flag either: if the socket opens, you are on the StandIn AI Teammate app. One agent instance serves **one** connection. The secrets belong to a single Microsoft organization - run a second instance for a second organization, and never share a secret across tenants. ## Bring your own Azure bot You register a Microsoft Entra app, create an Azure Bot resource, and upload your own Microsoft Teams app package. The bot carries your name and lives entirely in your tenant. Follow [Azure bot setup](/teams/azure-bot) and [App package](/teams/app-package). This is the right choice when identity custody matters more than setup time, or when you need the bot to be visibly yours. ## Endpoints StandIn dials your worker, so the URL you save in the portal has to match the path the worker listens on. Give the portal a bare host and it fills in the default below; paste a full URL if the worker uses a different path. **Calling is the same in both modes.** Only chat differs: | Lane | Public contract | Auth | What you expose | | ---------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | | Calling, both modes | WebSocket `wss://host/msteams/calling/{callId}` | HMAC handshake with `STANDIN_SECRET` | the call listener (`STANDIN_PORT` / `STANDIN_WS_PATH`, defaults `9442` and `/msteams/calling`) | | Chat, **StandIn AI Teammate app** | the worker dials `STANDIN_CHAT_URL` (default `wss://teams.standin.komaa.com/api/chat/channel`) | HMAC with `STANDIN_CHAT_SECRET`, else `STANDIN_SECRET` | nothing inbound | | Chat, **bring your own Azure bot** | the messaging URL on your Azure Bot | your Azure Bot credentials, not this SDK | not this SDK; the portal shows the value to register | A bring-your-own-bot identity answers Microsoft Teams chat on that Azure Bot messaging URL, not through `ChatChannel`. Calls still use the calling row. Take the URL the portal offers when you deploy the identity. The mount commands that publish the call path, what a healthy probe looks like, and the `--http1.1` gotcha that makes a working mount look broken. # Microsoft Teams setup overview Source: https://docs.komaa.com/teams/overview What you need to connect your agent to Microsoft Teams: an Azure bot identity, the StandIn app package, and a one-time upload to Microsoft Teams. This page is the **Microsoft** side of the setup, and it is the same whichever SDK, plugin or language you use. Only the agent side differs. **Bring-your-own-bot** means your tenant owns the bot identity, StandIn answers its calls, and your worker supplies the agent. If you would rather not own a bot at all, StandIn publishes one you can connect to instead: [Connection modes](/teams/connection-modes) compares the two. `standin-sdk`, with plugins for ElevenLabs, Deepgram, Cartesia, LiveKit and Hermes. `@komaa/standin-sdk`, with plugins for ElevenLabs, Deepgram, Cartesia, OpenAI Realtime, LiveKit and OpenClaw. Answer a call with an echo before wiring an agent to it. ## What you will create Three pieces, created once per bot identity: 1. **A Microsoft Entra app registration** - the bot's identity (its client ID becomes your `botId`). 2. **An Azure Bot resource** with the **Microsoft Teams channel** enabled - this is where you point Microsoft Teams at StandIn's calling webhook and (optionally) messaging endpoint. 3. **A Microsoft Teams app package** - a small zip (manifest + icons) that makes the bot callable in your tenant. **You do not hand-build it**: your StandIn dashboard generates it per identity, ready to upload. You need rights to create resources in an **Azure subscription**, and a **Microsoft Teams / Entra admin** (or someone who can act for you) to grant admin consent and allow the custom app. Budget **20-30 minutes** for the first run-through. ## Setup path Register the Entra app, create the Azure Bot resource, enable the Microsoft Teams channel with calling, and grant the Graph permissions. Follow [Create the Azure bot](/teams/azure-bot). Grab the generated zip from your StandIn dashboard (or build it from the example manifest). See [Get the Microsoft Teams app package](/teams/app-package). Upload the package as a custom app, or publish it to your organization. See [Upload to Microsoft Teams and go live](/teams/publish). Register your worker's **Agent calling URL** and the matching secret in StandIn, then place a Microsoft Teams call to your bot. The [quickstart](/quickstart) covers both. Would rather not register a bot in Azure at all? [Add StandIn AI Teammate to Microsoft Teams](https://teams.microsoft.com/l/app/efcbbdea-3d93-47f7-b106-f2c33126d0b5) from the Store and connect it as the [StandIn AI Teammate app](/teams/connection-modes#standin-ai-teammate-app). No app package needed. # Upload to Microsoft Teams and go live Source: https://docs.komaa.com/teams/publish Upload the app package as a custom app or publish it to your organization, register your agent in StandIn, and place the first call. Part 3 of the [Microsoft Teams setup](/teams/overview). You have the [app package](/teams/app-package) - now put it in front of Microsoft Teams users. Pick the route that matches how widely the bot should be available. ## Choose an upload route Best for testing and personal use. In Microsoft Teams, go to **Apps → Manage your apps → Upload an app**: Microsoft Teams Apps page with Manage your apps and the Upload an app button highlighted Choose **Upload a custom app** and select the zip. The bot is then available to **you**; open it and call. Microsoft Teams Upload an app dialog with the Upload a custom app option highlighted This requires **custom app upload** to be allowed for your account - if the button is missing, ask your Microsoft Teams admin (policy reference: [Manage custom app policies and settings](https://learn.microsoft.com/en-us/microsoftteams/teams-custom-app-policies-and-settings)). Official walkthrough: [Upload your app in Microsoft Teams](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/apps-upload). Best when you want validation and versioning. Go to [dev.teams.microsoft.com](https://dev.teams.microsoft.com/) → **Apps → Import app** and upload the zip (or **New app** and paste the manifest fields in the GUI). The portal validates the manifest, hosts the icons, and can then **Preview in Microsoft Teams**, **Download** the finished package, or **Publish → Publish to your org**. Official reference: [Microsoft Teams Developer Portal](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/build-and-test/teams-developer-portal). Best for making the bot callable by **everyone in your tenant**. Either publish from the Developer Portal (**Publish → Publish to your org**) or have an admin upload the zip directly in the **Microsoft Teams admin center** (**Microsoft Teams apps → Manage apps → Upload new app**). A Microsoft Teams admin approves the pending app before it appears for users. Official reference: [Publish a custom app to your organization](https://learn.microsoft.com/en-us/microsoftteams/upload-custom-apps). ## Register your agent in StandIn In your StandIn dashboard, register your **Agent calling URL** (`wss:///msteams/calling`) and the matching HMAC `secret`. The [quickstart](/quickstart#3-register-the-agent-voice-url) covers both, and [Expose your agent](/expose) covers making that URL reachable. On this SDK there is no chat URL to set: `ChatChannel` dials out to StandIn. The optional **Agent chat URL** field applies only when you serve Microsoft Teams chat yourself. ## Place the first call Before you do, prove the agent side on its own. The SDK ships an install check that rings your handler on loopback and reports whether audio came back, with no tunnel and no tenant involved, so a silent first call does not leave you guessing which of the two halves is at fault: Checking the install ([Python](/python-sdk/checking-the-install), [TypeScript](/typescript-sdk/checking-the-install)). Then search for the app in Microsoft Teams (or open it under **Apps → Built for your org** after an org publish) and **call the bot**. StandIn answers, dials your worker, and your agent speaks. **First chat message ignored?** Using **OpenClaw**, its chat channel drops direct messages from senders you have not approved (`dmPolicy: pairing`, the default) - approve yourself with `openclaw pairing approve msteams `. Using **Hermes**, an unknown sender gets a **pairing-code reply** instead of an answer - approve it with `hermes pairing approve teams ` (with `TEAMS_ALLOWED_USERS` set, unlisted senders are dropped silently instead). Details: [the bot ignores Microsoft Teams chat](/troubleshooting#the-bot-answers-calls-but-ignores-microsoft-teams-chat-dm-pairing). ## If something does not work * Bot never joins the call → check the **calling webhook** on the [Microsoft Teams channel](/teams/azure-bot#6-enable-calling-and-set-the-webhook) and that [Graph permissions](/teams/azure-bot#8-grant-graph-permissions) are admin-consented. * Bot joins but stays silent → usually a `secret` mismatch or an unreachable Agent calling URL; see [Troubleshooting](/troubleshooting). * Upload rejected → re-check the [package layout](/teams/app-package#packaging-it-manually) (files at the zip root) and bump the manifest `version` on republish. # Tiers and what each gives you Source: https://docs.komaa.com/tiers The two ways to connect an agent to StandIn: a Free identity on your own Azure bot, and the StandIn AI Teammate app. How to tell which one you are on, what each gives you, and what each costs you. There are two ways to get an agent onto a Microsoft Teams call with StandIn. They differ in **who owns the Microsoft Teams bot**, and that one difference decides how much Azure work you do and how your agent appears in Microsoft Teams. Nothing on your side of the seam changes with the tier. Same package, same handler, same `STANDIN_SECRET`. Read this page once to pick a lane, then never think about it again while you are writing worker code. ## Which one am I on? One question settles it: **who issued the Microsoft Teams bot?** | What you actually did | You are on | Who owns the bot | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | --------------------------------------- | | Registered your own Microsoft Entra app and Azure Bot, and deployed an identity on the free daily budget | a **Free identity** | you | | Added **StandIn AI Teammate** from the [Microsoft Teams Store](https://teams.microsoft.com/l/app/efcbbdea-3d93-47f7-b106-f2c33126d0b5), typed `signin` to it, and deployed an identity with **Use the StandIn AI Teammate app** | the **StandIn AI Teammate app** | StandIn, operated for your organization | The tell that never lies is the App ID. If you hold an Azure Bot App ID and client secret, you are on your own bot; if StandIn handed you a secret and you never saw an App ID, you are on the StandIn AI Teammate app. A bring-your-own-bot identity that is **not** on the free option is the same lane as a Free identity with the daily budget lifted: same Azure app, same app package, same endpoints, same handler. The free choice is a metering choice, not a different setup. ## Side by side | | Free identity | StandIn AI Teammate app | | ------------------------------------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------------- | | Azure app registration | you create and manage it | none | | Where the call happens | your own tenant | your own Microsoft organization | | Who can talk to your agent | people who dial your bot | people who dial StandIn in your organization | | Identity in Microsoft Teams | appears as your own bot | appears as StandIn | | Inbound calls dialed in your organization | yes | yes | | Chat | your runtime's own Microsoft Teams channel | `ChatChannel`, dialed out of your worker | | Meeting recap (`meetingRecap` / `meeting_recap`) | not this SDK's `ChatChannel`; wire `post_minutes` through your own channel | posts through `ChatChannel` | | Daily budget | 5 minutes of call time a day, per identity | what your StandIn plan allows | | One-time admin work | an admin consent before calling works | an admin consent before calling works | | Where you set it up | [Microsoft Teams setup](/teams/overview) | [Connection modes](/teams/connection-modes#standin-ai-teammate-app) | Chat is the row people misread, so read it twice. The chat lane in this SDK is what the **managed bot** unlocks: it dials out, so it needs no port and no tunnel. A bring-your-own-bot identity gets chat from its own Azure Bot messaging URL instead, which is not this SDK. [Endpoints](/teams/connection-modes#endpoints) is the only place that list lives. Meeting recap in the OpenClaw and Hermes plugins uses that same outbound chat socket. Turn the switch on on a bring-your-own-bot identity and hang-up writes the transcript, then has nowhere this SDK can post. Meeting recaps are best-effort. They may be lost if the worker exits during processing. ## Plans and billing Daily minutes and managed-bot capacity live on the identity in the [StandIn portal](https://standin.komaa.com/dashboard). Choose a plan there, set how long the teammate can talk, and turn on extras such as a knowledge base. The SDK authenticates with the connection secret. The portal owns the plan. The daily budget runs on a clock that is not yours: [the daily budget entry](/troubleshooting#free-identity-bot-leaves-or-will-not-join-daily-budget) says when it flips and why a bot that "hung up out of nowhere" at around five minutes is the budget rather than a fault. ## A Free identity **What it gives you.** The whole bring-your-own-bot setup: your own bot, in your own tenant, carrying your own name, answering inbound calls that people dial directly. Nothing is fenced off. It is the paid setup with a daily budget attached, which makes it the honest way to evaluate StandIn against a real tenant. **What it costs you.** The Azure work is yours: a Microsoft Entra app, an Azure Bot resource, a Microsoft Teams app package, and the Graph permissions with the one-time admin consent that turns calling on. Then 5 minutes of call time a day, per identity. Pick the free budget when you deploy the identity in the [dashboard](https://standin.komaa.com/dashboard). The setup itself is [Microsoft Teams setup](/teams/overview), and the permissions table that gates calling is [on the Azure bot page](/teams/azure-bot#8-grant-graph-permissions). ## The StandIn AI Teammate app **What it gives you.** No Azure app registration at all, and no App ID or client secret to rotate. You add StandIn AI Teammate from the [Microsoft Teams Store](https://teams.microsoft.com/l/app/efcbbdea-3d93-47f7-b106-f2c33126d0b5) (also listed in [Microsoft Marketplace](https://marketplace.microsoft.com/en-us/product/office/WA200012046)), type `signin` so StandIn learns which Microsoft organization to connect, and deploy an identity in the portal that points at your agent. It is also what unlocks this SDK's chat lane: `ChatChannel` dials **out**, so chat needs no URL registered, no open port and no tunnel rule, and your worker never holds a Bot Framework credential. **What it costs you.** The bot carries StandIn's name in your organization, not yours, which is the whole of the trade and the reason the other lane exists. Calling waits on a Microsoft 365 administrator approving it once for the organization; chat works before that approval lands, which is why a new connection can look half-broken when it is merely half-approved. And one agent instance serves one connection: the secrets belong to a single Microsoft organization, so a second organization means a second instance, never a shared secret. The canonical page for the Microsoft Teams side of this choice: the StandIn AI Teammate app step by step, bring your own Azure bot, the second chat key, and the endpoint table. ## What the tier does not change The tier decides who owns the bot and what you are allowed. It never reaches into your worker, so these hold everywhere: * **The package and the handler.** One SDK in [Python](/python-sdk/overview) and [TypeScript](/typescript-sdk/overview), the same callbacks, the same audio contract. Moving lane is a configuration change. * **One call path to publish, and one secret.** `STANDIN_SECRET` must byte-match the secret registered for the identity you are on, and a mismatch is silent by design. The path and the probes are on [Expose your agent](/expose), which is the only page that carries them. * **What you keep is yours to gate.** `recording_active` / `recordingActive` says whether the call is being recorded right now. What the SDK itself gates on it is vision: `AmbientVision`, whose `require_recording` / `requireRecording` is **on** by default, and `KeyframeStore`, which keeps no history while the call is not recorded. **The SDK does not gate audio on it.** The caller's speech reaches your handler whether or not the call is being recorded, so anything you store, transcribe to disk or send to a third party has to read the flag itself. Nothing in the SDK mirrors a call into an audit log and nothing redacts what your agent says or sends: if your organization needs either, it lives in your worker, on every tier. * **Outbound calling is off until you switch it on.** An unset `STANDIN_OUTBOUND_ALLOW` refuses every outbound call, and `STANDIN_OUTBOUND_MAX_PER_HOUR` caps the rest at six an hour by default. When nobody answers, **there is no voicemail**: the SDK has none. The parked line is posted back into the conversation the request came from, once, marked so it reads as a missed call. A call with no such conversation, which is every one-to-one call, is parked with no fallback rather than posted somewhere it does not belong, because a message delivered to the wrong thread is worse than one that waited. [What the SDK gives you](/concepts/features) is the full boundary between your worker, this SDK and the hosted service. ## Moving to another tier Your handler comes with you. What does not come with you is the identity: each one has its own registered secret, so switching means taking the new secret from the portal, setting `STANDIN_SECRET` to it and restarting the worker. Never carry one secret across two identities or two organizations. # Troubleshooting Source: https://docs.komaa.com/troubleshooting The failures a StandIn worker actually produces: a silent HMAC mismatch, an unreachable worker, a probe that lies, a bot that answers every turn of a meeting, an agent that answers itself, and the five timers that end a call. Work top to bottom: secret, reachability, address, then how the agent behaves once calls do connect, then limits. Most first-call failures are one of the first two, and both of them are silent by design. ## The bot joins but never speaks (HMAC mismatch) **Symptom:** the bot joins the call, then nothing. No greeting, no reply, no error in your worker's logs. `STANDIN_SECRET` must **byte-match** the secret registered for your identity in StandIn. A mismatch fails the handshake and the connection is dropped, so both sides look healthy while no audio flows. This is the number one first-call issue. It is silent on purpose. An endpoint that explains why it rejected you is an endpoint that helps an attacker, so the worker answers `401` and says nothing more. **Fix:** * Re-paste the secret on both sides. Watch for a trailing newline picked up when copying, and for shell quoting that eats characters such as `$` or `!`. Prefer single quotes: `export STANDIN_SECRET='...'`. * Confirm the variable actually reaches the process. `STANDIN_SECRET` is read at construction, so a value exported in a different shell, or set after the worker started, does nothing. * The secret is **not** re-read hot. Restart the worker after changing it. A rotated secret takes effect on the next handshake, so live calls finish on the old one and the next call in uses the new one. Nothing has to be re-registered and no support request is needed. * **Check the worker's clock.** The handshake signs a timestamp alongside the `callId`, and both SDKs refuse one more than **60 seconds** away from their own clock, in either direction, before they compare signatures at all. A host whose clock has drifted, or a container resumed from a suspended image, therefore answers `401` to every call while holding a byte-perfect secret. `verify_handshake` / `verifyHandshake` fails closed and says nothing, so this looks exactly like a mismatch. Compare `date -u` against a known good source, and run a time sync service. The window and why it is that tight are in Security ([Python](/python-sdk/security), [TypeScript](/typescript-sdk/security)). ## Bot does not join: "agent is not reachable" **Symptom:** the call never gets a bot, and the portal says the agent is not reachable. StandIn dials **your worker** from the internet. If it cannot reach your `wss://` endpoint, there is nothing to bridge to and no bot joins. **Fix, in order:** 1. **Does your own handler answer at all?** Run the install check before you suspect the network. It binds an ephemeral listener on loopback, speaks the real call wire to your handler, streams a few frames and reports whether audio came back, with no tunnel, no provider bill and no Microsoft tenant involved. A worker that fails there was never going to answer a real call, and no amount of tunnel debugging changes that. See Checking the install ([Python](/python-sdk/checking-the-install), [TypeScript](/typescript-sdk/checking-the-install)). 2. **Is the worker running?** Start it and confirm the startup line naming its bind address and path. `curl -s http://127.0.0.1:9442/healthz` should answer `{"ok": true, "calls": 0}`. 3. **Is the bind reachable?** `STANDIN_HOST=127.0.0.1` is invisible from the internet unless a tunnel forwards to it. Either keep the tunnel in front, or bind `0.0.0.0` behind an ingress. 4. **Is the tunnel up?** A dropped tunnel is the most common cause after the secret. Restart it and test the public URL from outside your network. 5. **Does the registered URL match?** The agent calling URL in StandIn must point at the live endpoint, including the path. See [Expose your agent](/expose). ## A voice endpoint probes as 404 through a funnel **Symptom:** `tailscale funnel status` shows the rule, the worker is running, and a curl probe with WebSocket headers still returns `404`, which looks exactly like a missing mount. This is an **HTTP/2 false negative**. The probe is wrong, not the endpoint. Tailscale Funnel serves HTTP/2 to the client, and HTTP/2 has no connection-level upgrade. Over h2 your `Connection: Upgrade` and `Upgrade: websocket` headers never travel as a handshake: what reaches the worker is an ordinary `GET`, and a TypeScript worker answers that `404` because nothing but an upgrade belongs on that path. So the `404` is about the probe, not about the mount. A Python worker answers the same degraded probe `401`, because it verifies the signature before it looks at the upgrade. That is the more dangerous outcome: it looks like a pass. It does prove the mount reaches your worker, and it proves nothing about whether the tunnel forwards a real upgrade, which is the only thing a call needs from it. Force HTTP/1.1 either way, and include a call id segment: ```bash theme={null} curl -si --http1.1 \ -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ https://..ts.net/msteams/calling/probe | head -1 ``` `401` is the healthy answer: the route exists and refused an unsigned probe. **The bare path 404s on a healthy worker.** The SDK registers the route as `/msteams/calling/{callId}`, so `/msteams/calling` with no segment has no route at all. A path mount forwards everything underneath it, so the `/probe` segment reaches the worker either way. **A plain `GET` is not a health check for calls, and its status code differs by SDK.** With a call-id segment and no upgrade, a healthy worker on loopback answers `401` in Python and `404` in TypeScript: the Python listener verifies the signature before it ever looks at the upgrade, and the TypeScript listener only ever routes upgrades there. Neither is a liveness signal. Use `GET /healthz` at the root of the call port for liveness, and the upgrade probe above for routing. ## Wrong port or path | | Default | Environment variable | | ------------------ | ------------------ | -------------------- | | Call listener port | `9442` | `STANDIN_PORT` | | Bind address | `0.0.0.0` | `STANDIN_HOST` | | Call path | `/msteams/calling` | `STANDIN_WS_PATH` | A URL registered with a stale port, or with the path dropped by the proxy target, connects to nothing and the call never reaches your agent. Register the public `wss://` URL, never the local `ws://` bind. With a path mount there is no port in the value; with a whole-port tunnel there is, and you have to type it correctly everywhere, which is one of several reasons to prefer the path mount. Each call carries its own `callId`, and the handshake signs it per call. You never configure a `callId`. If logs show signature failures for one specific `callId`, that is still the shared secret, not the id. ## Calls connect but the bot talks over the caller **Symptom:** the caller interrupts, the model stops generating, and the bot keeps talking for another second or two anyway. Your handler cancelled the response upstream without cancelling the playback. Audio already handed to StandIn is still queued at the service, and nothing upstream can reach it. **Fix:** call `cancel_playback()` / `cancelPlayback()` **first**, then cancel the provider: ```python Python theme={null} await session.cancel_playback() # un-send what StandIn still holds await provider.cancel_response() # then stop the model generating more ``` ```typescript TypeScript theme={null} await session.cancelPlayback(); // un-send what StandIn still holds await provider.cancelResponse(); // then stop the model generating more ``` It is the only wire-level lever that un-sends buffered audio. See [Dialogue modes](/concepts/modes#barge-in-works-the-same-either-way). ## The last word of every turn is clipped **Symptom:** replies sound fine except that the final syllable is missing, consistently, on every turn. A resampled buffer does not divide evenly into the wire's 640-byte frame, and the remainder is being dropped. Use `FrameAligner` and flush it at the end of a turn rather than discarding the tail. This is exactly why the aligner is in the SDK: see [Architecture](/concepts/architecture#audio-and-why-the-helpers-are-in-the-sdk). ## The bot answers every turn of a meeting **Symptom:** in a 1:1 call the agent behaves. Invited to a meeting, it replies to everything anybody says, and the meeting cannot continue until somebody mutes or removes it. The group gate is not doing anything. `GroupGate` keeps the agent quiet until somebody addresses it by a wake phrase, then leaves the floor open for a follow-up window, 12 seconds by default, so the next sentence does not need the name again. A 1:1 call always answers. Two mistakes switch the gate off without looking like they did: * **No wake phrase configured.** A gate with nothing that can open it would mute the agent for the whole call, so a gate whose phrase list is empty treats itself as off and answers everything. Configure the phrases your callers will actually say. * **Deciding "is this a group call?" from the participant count.** A participant count never arrives on the meeting-join path, so a gate that waits for one is dead on exactly the calls it exists for, and the failure reads as a gate that was never switched on. The signal that does arrive is the thread id: Microsoft's id for a meeting or channel conversation begins `19:`, and it is in `session.start` on every call. Pass it as `thread_id` / `threadId` when you build the gate, and treat `note_participants()` / `noteParticipants()` as corroboration only. It can add certainty; it must never remove it. Group calls ([Python](/python-sdk/group-calls), [TypeScript](/typescript-sdk/group-calls)) has the gate, the follow-up window and the verbal interrupts in full. ## The agent answers itself, and the caller cannot get a word in **Symptom:** the caller is on a speakerphone, or on a laptop with no headset. The agent says something, then answers itself, then answers that, in a loop, with the caller silent throughout, until somebody hangs up. The agent's own voice is coming out of the caller's speaker and back into the caller's microphone. It arrives at your handler as caller audio, the provider's voice detection hears a finished turn, and the model replies to it. This is not a subtle degradation: the call has been taken over. **Fix:** run the echo guard over inbound frames. It keeps a playout clock, and that is the part worth understanding: wall-clock send time is useless here, because a realtime model streams audio far faster than realtime, so a guard keyed on "when did we last send" disarms in the middle of the sentence it exists to cover. The guard accumulates the **duration** of what was sent instead, and while that playout is still likely to be audible it drops inbound audio unless the frame is loud enough to be a person really interrupting: `ECHO_BARGE_IN_RMS`, `0.04`, with a tail of `ECHO_SUPPRESSION_WINDOW_MS`, 600 ms, for the network and jitter buffer between you and their speaker. Until the caller's first real turn, no barge-in is allowed at all, because the opening greeting echoing back is exactly the loud thing that would otherwise make the agent interrupt and re-greet itself. In Python the guard tracks that itself once you call `mark_caller_turn()`; in TypeScript you pass `allowBargeIn: false` until the caller has spoken. The two SDKs are genuinely different shapes here, and a port that assumes otherwise runs and then fails on a real call: Python has an `EchoGuard` class that owns its clock, TypeScript has `shouldSuppressEcho`, a pure function whose answer is inverted, `true` meaning drop the frame, and whose playout horizon you keep yourself on the epoch-millisecond scale. Realtime providers ([Python](/python-sdk/realtime-providers), [TypeScript](/typescript-sdk/realtime-providers)) has both, side by side. ## The caller's first sentence never reaches the agent **Symptom:** the agent opens by asking something the caller already answered. Or the group gate never engages, or a recording gate never opens, even though the context did arrive. A call starts the moment StandIn dials. Your agent starts a little later: a socket has to open, a room has to be joined, a provider session has to be configured. Everything the caller does in that gap still arrives, and if nothing holds it, it is gone. People start talking the instant a call connects, and the first thing they say is often the reason they called. The "there are three people here, stay quiet unless addressed" sentence and the recording-status change land in the same gap. **Fix:** hold what arrives and release it when the agent is ready. `StartupBuffer` keeps caller audio and call context in two lanes, preserves order within each, and releases audio **before** context, because the provider needs the words in the order they were said and the context is a note about the call rather than part of the conversation. It is bounded on purpose: `MAX_PENDING_AUDIO` is 200 frames, about four seconds of speech, and `MAX_PENDING_CONTEXT` is 20 lines. It drops the **oldest** rather than the newest, so if something has to be lost it is the stale audio rather than what just happened, and a socket that never opens cannot grow a buffer for the length of the call. Log `dropped` when it is not zero: it means your startup took longer than the buffer covers, which is a different problem from the one this fixes. Realtime providers ([Python](/python-sdk/realtime-providers), [TypeScript](/typescript-sdk/realtime-providers)) has the buffer and its release order. ## The call ends by itself after about 45 seconds That is the caller-audio idle watchdog, and it is doing its job: a live call that has stopped carrying caller audio is a call nobody is on. If it is firing during real conversation, caller audio is not arriving, which usually means the media path degraded rather than that the timer is wrong. The related timers are a 10 s pre-start watchdog, for a socket that authenticated and never sent `session.start`, and a 15 s `on_start` timeout, for a handler whose startup hangs. A handler that routinely needs longer than 15 s to be ready should return from `on_start` early and finish warming up in the background. Five timers can end a call on their own. The other two have their own sections below, because they look nothing like this one: the unanswered-call reaper at about two minutes, and the call-duration ceiling you set yourself. ## The bot joins, stays silent, and the call drops after about two minutes **Symptom:** the call connected, the caller talked the whole time, the agent never said anything, and at around 120 seconds the call ends. The close reason is `no-agent-answered`, and your log carries the line that names the call. Each SDK prints the timer in its own unit, so grep for `nothing answered call` rather than for a number: Python logs `standin: nothing answered call within 120s; ending it` and TypeScript logs the same sentence with `within 120000ms`. Nothing ever answered the call. This is the gap none of the other watchdogs cover: the pre-start watchdog is satisfied because `session.start` arrived, the `on_start` timeout is satisfied because `on_start` returned, and the idle watchdog is satisfied because the **caller** is still talking. No audio ever went out, which is what a dispatch that never lands, or a provider session that never opened, looks like from the listener's side. **Fix:** the cause is on your side of the seam, so find out why the agent produced no audio. Sending audio counts as answering, so a handler that speaks at all is covered without doing anything. A plugin that answers by a route the listener cannot see, joining a room or handing the call to a provider, has to say so: call `session.mark_answered()` / `session.markAnswered()` when the **agent's own** audio track appears, not when any participant connects. Monitors, recorders and avatar workers all connect, and none of them is an agent answering. The timer is `stale_call_reaper_seconds`, default `120.0`, and `staleCallReaperMs`, default `120_000`. Two minutes either way, but the names carry their unit and a value copied across the two SDKs unchanged is off by a thousand: `staleCallReaperMs: 120` reaps every call within a quarter of a second of connecting. `0` disables it in both, and disabling it is rarely what you want: it only ever frees a call that is already lost, and it costs nothing on a call that was answered. ## The call ends politely at a fixed time **Symptom:** the call ends at the same elapsed time every time, and the caller hears a closing line first, by default "We are out of time on this call, so I have to stop here. Goodbye." The close reason is `call-duration-limit`. That is the call-duration ceiling, `max_call_seconds` / `maxCallMs`. It is **off by default**, so if you are seeing it, you or your plugin's configuration set it. It is a hard ceiling measured from `session.start`, and it is a different thing from the idle watchdog: idle ends a call that went quiet, this one ends a call that is still going. The caller who will not hang up, the model looping at itself and the automated system that dialled and never stopped talking all bill a provider by the minute for as long as the socket lives, and none of them trips a silence check. The line is `goodbye_text` / `goodbyeText`, delivered through the handler's `on_goodbye`, the same callback StandIn's own closing line uses, so no plugin needs new code to honour it. Playback is flushed first, or the line would queue behind whatever audio is still in flight and the call would end before anyone heard it. How long it has to finish is `goodbye_grace`, default `6.0`, and `goodbyeGraceMs`, default `6000`: six seconds either way, and the name carries the unit here too. ## Calls are refused with 503 The worker is draining, or every slot is taken. Both are deliberate: a worker that refuses a call it cannot serve beats one that accepts it and half-serves it. Capacity is checked **before any signature work**, so a flood of calls you were never going to take costs no crypto. The ceiling is `max_connections` / `maxConnections`, **64** by default. It is a constructor argument and not an environment variable, so raising it is a code change and no `STANDIN_` setting will do it: call server ([Python](/python-sdk/call-server), [TypeScript](/typescript-sdk/call-server)). Watch `GET /healthz` for the live call count to see a drain finish. A `409` instead means that call id already has a live session, which happens if a previous socket for the same call has not finished tearing down. ## Voice works, chat is silent, and nothing appears in any log The diagnosis is this: the chat lane is dialed **out** by `ChatChannel`, so there is no mount to get wrong and no inbound request to lose. An empty log means the channel was never started, or it failed to open. Check that you called `start()` on it, that a secret is set in that process too, and that your identity is on the StandIn AI Teammate app: the chat lane exists only there. The lane reads `STANDIN_CHAT_SECRET` first and falls back to `STANDIN_SECRET`, so a StandIn AI Teammate app connection that issued a separate chat key needs that key here. Unset `STANDIN_CHAT_SECRET` rather than blanking it: in TypeScript an exported empty string counts as set and shadows a perfectly good `STANDIN_SECRET`, so the channel refuses to start at all, while in Python the blank falls through and the lane opens. Then look for the `standin: chat channel open` line at startup. Without it, nothing is connected. ## Meeting recap is on, the call ended, and nothing landed in chat The plugin wrote the transcript (look under `STANDIN_RECAP_DIR` or `~/.standin/state/recap`) and then had nowhere this SDK can post. * **Bring your own Azure bot / Free identity:** chat is your Azure Bot messaging URL, not `ChatChannel`. This switch will not post there. The host must also have a summarization consult. * **StandIn AI Teammate app:** `ChatChannel` must actually open. Same checks as [chat is silent](#voice-works-chat-is-silent-and-nothing-appears-in-any-log): secret, `start()`, and a chat key if StandIn issued a separate one. Recap opens the lane listen-only, so it will not answer chat even when posting works. Hang-up does not await the consult. Meeting recaps are best-effort. They may be lost if the worker exits during processing. ## The bot answers calls but ignores Microsoft Teams chat (DM pairing) **Symptom:** voice works end to end, the message reaches your agent, and the bot never replies. Your agent declined to act on it. Both runtimes gate direct messages from senders they do not know, and this is their gate rather than StandIn's. **OpenClaw: closed by default, and silent.** With `dmPolicy: pairing`, DMs from unapproved senders are dropped and a pairing request is queued without telling the sender. List and approve: ```bash theme={null} openclaw pairing list msteams openclaw pairing approve msteams ``` **Hermes: gated too, but visible by default.** With no allowlist, an unknown sender gets a pairing code back instead of an answer. Approve it once: ```bash theme={null} hermes pairing approve teams ``` Hermes goes silent only when `TEAMS_ALLOWED_USERS` (or the global `GATEWAY_ALLOWED_USERS`) is set: unlisted senders are then dropped with an unauthorized-user warning and no pairing code is offered. Hermes rate-limits pairing replies to one per sender per 10 minutes, so a rapid second test message getting no answer is expected. Wait, or approve the code you already have. ## A picture is refused on its way into a chat **Symptom:** the agent tries to attach an image and gets an error back instead, so the reply arrives without it, or not at all. Every outbound picture is checked before it is sent, and there are three messages, each meaning something different: | What you get | Why | | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cannot be sent inline; it must be one of image/png, image/jpeg, image/gif, image/webp` | Only those four raster types may be rendered inline. `image/jpg` is normalised to `image/jpeg` first, so that spelling is never the cause. `image/svg+xml` is deliberately absent and is not a gap to be filled later: SVG is scriptable XML, which is precisely what an image must not be. | | `that image is bytes, over the 1048576 limit` | Decoded bytes are capped at `OUTBOUND_IMAGE_MAX_BYTES`, 1 MiB. Re-encode or downscale rather than raising it. | | `those bytes are not image/png: they look like image/gif` | The declared type is a claim made by whatever produced the bytes; the signature is what they are. Without this check an HTML document or an SVG labelled `image/png` gets posted into somebody's chat under your bot's name. | A local file has one gate before all three. Reading one is off until you name a directory, and until you do, `sending a local file is off until a directory is named in STANDIN_MEDIA_ROOTS` is the answer. A path outside those roots is refused with `that file is outside the directories this worker may read`, with symlinks resolved first, because the path reaching that call was chosen by a model somebody else is steering. Sending a picture ([Python](/python-sdk/sending-pictures), [TypeScript](/typescript-sdk/sending-pictures)) covers the checks and the filename rules. For a picture arriving the other way, see chat attachments ([Python](/python-sdk/chat-attachments), [TypeScript](/typescript-sdk/chat-attachments)). ## An outbound call is refused before it rings **Symptom:** the agent says it cannot call somebody, and no phone rings anywhere. That is the outbound policy, and it is working. Everything that can be refused is refused **before** the call is placed, so a refusal never leaves somebody's phone ringing for a message that was rejected. Each refusal is one sentence, safe to read out loud: | What comes back | Why | | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `outbound calling is off: set STANDIN_OUTBOUND_ALLOW to the directory ids this agent may ring` | The allowlist is empty, which is the default. Outbound calling stays off until you list somebody. | | `that person is not on this agent's outbound allowlist` | The target is not on the list. Matching is case-folded on both sides, so a case mismatch is not what you are looking at. | | `an outbound call needs the person's directory id` | No target at all reached the policy. | | `this agent has already placed 6 calls in the last hour` | `STANDIN_OUTBOUND_MAX_PER_HOUR`, default `6`, counted over a rolling hour across all targets. `0` means no cap, which is a choice rather than a default. | **Fix:** put the directory id in `STANDIN_OUTBOUND_ALLOW`, comma separated, and restart the worker. The list is read when the policy is built. This list is separate from any inbound allowlist, and stricter, because the two answer different questions. Inbound asks whether a person may talk to the agent, and that person chose to dial. Outbound asks whether the agent may ring a person, and the agent was talked into it by whoever is on the call. An agent with an outbound tool and no allowlist is an agent that can be talked into cold-calling your directory. Reaching people ([Python](/python-sdk/reaching-people), [TypeScript](/typescript-sdk/reaching-people)) has the rest of the lane. ## Free identity: bot leaves or will not join (daily budget) A **free identity** is metered: **5 minutes of call time per day, per identity**. When the budget runs out the bot leaves and returns the next day. The cutoff is **abrupt**: at the 5-minute mark the bot leaves immediately, even mid-sentence, with no warning. A bot that "hung up out of nowhere" at around five minutes is the budget, not a fault. "The next day" flips at **midnight UTC**, not your local midnight, which is why a bot can work again "early in the morning" or "only after lunch" without anything having changed. ## A package name will not resolve There are two package names, one per language, and everything ships inside them: ```bash theme={null} pip install standin-sdk # Python, imported as standin npm install @komaa/standin-sdk # TypeScript ``` Framework support is an extra or a subpath of those, never a separate package: `standin-sdk[livekit]`, `standin-sdk[hermes-agent]`, `@komaa/standin-sdk/openclaw`. Two Python extras gate a shipped capability rather than a framework, and a base install hits a missing dependency the first time it uses one: `standin-sdk[tile]` encodes frames for the avatar tile, and `standin-sdk[render]` draws a document onto that tile. Office formats additionally need LibreOffice on `PATH`, which is not something pip can fetch. `standin-sdk[all]` pulls all of them in at once. Still stuck? Re-read the [quickstart](/quickstart) end to end. Most remaining issues are a variable that never reached the process, or a Graph permission that was never admin-consented. ## Contact support Include the `callId` from your worker's logs, which SDK and plugin you are running, and roughly when the call happened. We do not keep transcripts, so that context is what we work from. **Support: support \[at] komaa.com** StandIn is operated by Komaa DigiTech. For privacy requests and data-subject rights, see the [Privacy Statement](https://standin.komaa.com/privacy). # Audio Source: https://docs.komaa.com/typescript-sdk/audio Sample rates, the 640-byte frame, resamplePcm16 and FrameAligner in the StandIn TypeScript SDK. The StandIn wire is PCM 16 kHz, 16-bit, mono, little-endian in both directions. Almost nothing else is. The realtime speech-to-speech models speak 24 kHz, most TTS vendors emit 22.05 or 24 kHz, and none of them chunk on the wire's frame boundary. So every plugin that is not a pure passthrough ends up writing the same two things: a resampler, because the rates differ, and a frame aligner, because a resampled buffer does not divide evenly into the wire's 640-byte frame. They ship in the SDK because they are properties of the **wire**, not of any framework, for the same reason the sequence number lives in `CallServer`. A plugin that has to reimplement them is a plugin the SDK failed. ## Constants ```ts theme={null} import { SAMPLE_RATE_HZ, // 16000, the wire REALTIME_SAMPLE_RATE_HZ, // 24000, what realtime models speak FRAME_MS, // 20 FRAME_BYTES, // 640 = 16000 * 2 * 20 / 1000 BYTES_PER_SAMPLE, // 2 NUM_CHANNELS, // 1 } from "@komaa/standin-sdk"; ``` `REALTIME_SAMPLE_RATE_HZ` is named in the SDK rather than in a plugin because the **ratio** is what forces the residual buffer below, and that is an audio concern rather than a provider one. ## `frameDurationMs(pcm)` Duration of a PCM16 mono buffer in milliseconds. Use this for a playout clock rather than counting frames: outbound chunk lengths are not fixed, so a frame count drifts against real time. ```ts theme={null} import { frameDurationMs } from "@komaa/standin-sdk"; playbackEndAt = Math.max(playbackEndAt, Date.now()) + frameDurationMs(pcm16k); ``` Knowing when your own audio stops playing is what lets an echo guard tell the caller's voice apart from your agent's voice coming back up a speakerphone. That clock is the whole basis of the guard, and [Realtime providers](/typescript-sdk/realtime-providers#a-playout-clock-not-a-send-clock) is where it is spelled out. ## `pcm16Rms(pcm)` How loud one frame is, as root mean square normalised to 0.0 to 1.0. ```ts theme={null} import { pcm16Rms } from "@komaa/standin-sdk"; const loud = pcm16Rms(pcm) >= 0.02; ``` It lives here because three separate things want it and every plugin that wanted it had been writing it again: the [utterance segmenter](/typescript-sdk/voice#one-utterance-per-phrase) opens its gate on it, the [echo guard](/typescript-sdk/realtime-providers#the-agent-answering-itself) decides echo from barge-in with it, and a barge-in check of your own can use it directly. It is dependency-free and loop-simple for the same reason the resampler is: it runs on every inbound frame of every call. An odd trailing byte is dropped rather than throwing, and an empty buffer is `0`. ## Agent audio can be dropped Everything above gets whole frames onto the wire. The wire can still refuse them. When the socket has more than 1 MB (`MAX_AUDIO_BUFFER_BYTES`) unflushed, `sendAudio` advances the timeline and returns **without sending**, logging at most once every five seconds. The caller hears a gap rather than the call wedging, and the timeline still advances because a dropped frame is a gap in what they hear rather than a rewind: stalling the clock would make every later frame claim a time that has already passed. Control frames are never shed, because a call that cannot be ended is the failure this exists to prevent. Watch `session.bufferedBytes` before pushing a continuous stream, and treat a zero as no evidence of backpressure rather than proof of an idle socket, because it reads zero when the transport cannot report it. See [Call server](/typescript-sdk/call-server#limits). The avatar tile has its own, tighter budget on the same socket, because a caller forgives a dropped frame far more readily than a break in the voice. That one is `TileStream`'s, on [The avatar](/typescript-sdk/avatar). ## Inbound audio `onCallerAudio` hands you the caller's PCM already decoded and already validated: base64 that is not canonical is rejected, and so is a payload that is not a whole number of PCM16 samples. A malformed frame is dropped by the server with a log line and never reaches your handler. What you do **not** get is a guarantee of 640 bytes. 20 ms is the nominal frame, and it is what arrives on a normal call, but the only invariant the decoder enforces is a non-empty, even number of bytes: at least one whole PCM16 sample, and no half sample at the end. Measure with `frameDurationMs(pcm)` rather than counting frames, and never index into a caller frame at a fixed offset. ## `resamplePcm16(pcm, srcHz, dstHz)` Linear-interpolation resample of PCM16 mono. Returns the input untouched when the rates match or the buffer is empty. ```ts theme={null} import { REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ, resamplePcm16 } from "@komaa/standin-sdk"; const toModel = resamplePcm16(pcm16k, SAMPLE_RATE_HZ, REALTIME_SAMPLE_RATE_HZ); const toCaller = resamplePcm16(pcm24k, REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ); ``` An odd trailing byte is dropped rather than throwing. A truncated frame is a glitch; an exception in the audio path is a dropped call. The rounding is deliberately fussy, so that the Python and TypeScript SDKs produce byte-identical output: `Math.round` for the output length, and `Math.trunc` rather than `Math.floor` when interpolating, because Python's `int()` truncates toward zero and the difference is audible between a negative and a less-negative sample. The shared conformance vectors assert the two agree. ## `FrameAligner` Chops arbitrary-length PCM buffers into whole wire frames, carrying the remainder. Resampled 24 kHz deltas do not divide evenly into the 640-byte frame. Without a residual, the leftover bytes are dropped and every turn loses a few milliseconds at the seams. Over a call that is audible as clipped word endings. ```ts theme={null} import { FrameAligner } from "@komaa/standin-sdk"; const aligner = new FrameAligner(); // arbitrary lengths in, whole 640-byte frames out for (const chunk of providerAudio) { for (const frame of aligner.push(chunk)) { await session.sendAudio(frame); } } // end of turn: the residual is real audio, not a fragment to discard const tail = aligner.flush(); if (tail) await session.sendAudio(tail); ``` | Member | Purpose | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `new FrameAligner(frameBytes?)` | Defaults to `FRAME_BYTES`, 640. Pass another size only when you are aligning to something that is not the StandIn wire. | | `push(pcm)` | Add a buffer, return whatever whole frames are now available. | | `flush()` | Zero-pad and return the residual at end of turn, or `undefined` when empty. | | `reset()` | Drop the residual without emitting it. | | `pending` | Bytes currently held back. | `flush()` pads rather than drops: the tail of the last word matters more than a few milliseconds of silence. `reset()` is the barge-in case, where the held-back bytes belong to a turn the caller just interrupted and must never be played. ### The remainder problem, in numbers A 24 kHz provider does not hand you round numbers, and whether a chunk divides cleanly is luck: | From the provider, at 24 kHz | Resampled to 16 kHz | Whole 640-byte frames | Left over | Lost per seam without a residual | | ---------------------------- | ------------------- | --------------------- | --------- | -------------------------------- | | 9600 bytes | 6400 bytes | 10 | 0 bytes | nothing | | 7000 bytes | 4666 bytes | 7 | 186 bytes | 5.8 ms | Five milliseconds sounds like nothing. It is not one seam: it is one per chunk, for the whole call, and it always lands at the end of the audio rather than in the middle, which is why it is heard as clipped word endings rather than as a glitch. The aligner carries those 186 bytes into the next push and they are never lost at all. ### Resetting on a barge-in Three steps, and the order is the whole point: ```ts theme={null} await session.cancelPlayback(); // 1. flush what the service still holds provider.cancelResponse(); // 2. stop the model generating more aligner.reset(); // 3. drop the residual of the interrupted turn ``` Do 2 before 1 and the model stops while the bot keeps talking for the length of the buffer. Skip 3 and the first frame of the caller's **next** answer is prefixed with the last few milliseconds of the turn they interrupted, which sounds like the agent stammering. ## A complete realtime leg Both directions of a 24 kHz provider, with the interrupt handled properly: ```ts theme={null} import { FrameAligner, REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ, frameDurationMs, resamplePcm16, type CallSession, } from "@komaa/standin-sdk"; class RealtimeHandler { #call: CallSession | undefined; #aligner = new FrameAligner(); #playbackEndAt = 0; async onStart(session: CallSession) { this.#call = session; // model -> caller: 24 kHz down to the wire's 16 kHz, then whole frames only provider.on("audio", async (pcm24k: Buffer) => { const pcm16k = resamplePcm16(pcm24k, REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ); this.#playbackEndAt = Math.max(this.#playbackEndAt, Date.now()) + frameDurationMs(pcm16k); for (const frame of this.#aligner.push(pcm16k)) { await session.sendAudio(frame); } }); // end of turn: send the padded residual so the last word is not clipped provider.on("turnEnd", async () => { const tail = this.#aligner.flush(); if (tail) await session.sendAudio(tail); }); // barge-in: flush the service FIRST, then stop the model, then drop the residual provider.on("callerStartedSpeaking", async () => { await session.cancelPlayback(); provider.cancelResponse(); this.#aligner.reset(); }); } // caller -> model: the wire's 16 kHz up to what the model speaks async onCallerAudio(pcm: Buffer) { provider.sendAudio(resamplePcm16(pcm, SAMPLE_RATE_HZ, REALTIME_SAMPLE_RATE_HZ)); } } ``` ## Three traps worth naming 1. **Do not track timestamps yourself.** `sendAudio` owns the sequence number and the outbound timeline. Advancing your own clock and sending it is how timelines end up jumping backwards after an audio source is swapped. 2. **Do not drop the residual at end of turn.** That is the clipped-word-endings bug, and it is subtle enough to survive a demo and fail in production. 3. **Do not reuse one `FrameAligner` across calls.** It carries per-turn state. One per call, and `reset()` on every interrupt. ## The same bytes in both SDKs `standin/audio.py` and `src/audio.ts` are the same constants, the same linear resample and the same carry-the-remainder aligner, and the shared conformance vectors assert that both produce identical output for the same input. The rounding above is a property of the pair rather than of this language: it is written the way it is so that Python and TypeScript agree, not because either idiom is nicer. That matters in practice because a Python worker and a TypeScript worker can share one StandIn identity, and a call recorded from one has to be indistinguishable from a call recorded from the other. ## Next The segmenter, WAV handling and paced playback, for an agent that is not a realtime model. The playout clock, and why the agent answers itself on a speakerphone. `sendAudio`, `cancelPlayback` and the session that owns the timeline. Prove audio made the round trip without placing a real call. # The avatar Source: https://docs.komaa.com/typescript-sdk/avatar Expressions, the viseme timeline that drives lip-sync, and putting your own video on the bot's tile, in the StandIn TypeScript SDK. StandIn renders the avatar tile. Your worker does not draw it, stream it, or know how it is made. It sends hints, and the service does the rest. ## Two hints and one stream There are two hints. One names the emotion the face should wear, the other carries the viseme timeline for an utterance, which is what makes the mouth match the words: ```ts theme={null} await session.express("happy"); await session.sendSpeechMarks([ { tMs: 0, visemeId: 12 }, { tMs: 200, visemeId: 4 }, { tMs: 400, visemeId: 14 }, { tMs: 800, visemeId: 8 }, ]); ``` Everything here is **additive and best-effort**. An unknown emotion renders as neutral, a service that does not implement one of these ignores it, and nothing on this page changes a single sample of the audio the caller hears. That is the whole reason it is safe to send: the worst case is a neutral face over an unchanged voice, which is exactly what the caller would have got had you sent nothing at all. Neither hint needs a provider that offers it. The emotion can be read straight out of the reply text with no extra model call, and the timeline can be built for a provider that returns no timings whatsoever. Those two are the bulk of this page. Then there is the stream, for the minority of workers that already produce video of their own: `TileStream` puts that on the tile in place of the rendered avatar. It is the one thing here that is continuous rather than a hint, and the last section is mostly about not letting it hurt the voice. ## The emotion the face wears ```ts theme={null} import { EMOTIONS, expression } from "@komaa/standin-sdk"; EMOTIONS; // ["neutral", "happy", "sad", "surprised", "thinking"] ``` `EMOTIONS` is an **open set**, not a validation list, and the `Emotion` type says so in the types: it is one of those five, or any other string. Sending something outside the set is allowed and renders as neutral, which is what lets a newer sender and an older service keep interoperating instead of one of them refusing the other's vocabulary. `session.express(emotion)` is what a handler calls. `expression(emotion)` is the message builder underneath it, exported for a plugin that assembles its own wire messages. ```ts theme={null} import { MAX_EMOTION_CHARS } from "@komaa/standin-sdk"; MAX_EMOTION_CHARS; // 40 ``` `expression()` throws on an empty emotion or one longer than `MAX_EMOTION_CHARS`. Everything in between is the service's to interpret. The cap is applied **where the message is built**, not at the edge of some plugin, and that placement is the point. An emotion reaches the avatar tile, and the string usually came from a model that whoever is on the call is steering: a tool argument, a token off a transcript. Bounding it at the builder means `session.express` is bounded, every plugin's own `express` tool is bounded, and no future one can forget to do it. ## Emotion with no extra model call An extra inference per sentence is not something a live call can pay for. `inferEmotion` reads the emotion out of the reply text instead, with no model call and no added latency: ```ts theme={null} import { inferEmotion } from "@komaa/standin-sdk"; inferEmotion("Congratulations, that is excellent"); // "happy" inferEmotion("Sorry, that is great news"); // "sad" inferEmotion("They did what??"); // "surprised" inferEmotion("the meeting is at four"); // "neutral" ``` It returns `surprised`, `sad`, `happy` or `neutral`, typed as `InferredEmotion` and ready to hand to `expression()`. First match wins in that order rather than a score being totalled: a startled "wow" must not be averaged away by a polite "thanks", and an apology must not be masked by an incidental "nice". `ExpressionCue` decides **when** one is worth sending, which is the harder half. Ask it on every assistant transcript, partial and final alike, and send whatever comes back: ```ts theme={null} import { ExpressionCue } from "@komaa/standin-sdk"; const cues = new ExpressionCue(); async function onTranscript(text: string, isFinal: boolean): Promise { const emotion = cues.cue(text); if (emotion !== null) await session.express(emotion); } ``` Two things are happening there. It **re-reads the emotion on every chunk**, so the face self-corrects as the rest of a sentence arrives: "let me check that" cues neutral, and "let me check that, no way!!" cues surprised a moment later. And it **returns `null` when nothing changed**, which is what stops a partial-per-word stream from sending dozens of identical messages for one sentence. Waiting for the final transcript instead would land the cue as the sentence ends, with the face stale for the entire time a happy or apologetic reply was being spoken. One instance per call. What it remembers is the last emotion that call was told to wear, readable as `cues.lastSent`. While a tool keeps the caller waiting, hold a thinking face: ```ts theme={null} async function runTool(name: string): Promise { const waiting = cues.thinking(true); if (waiting !== null) await session.express(waiting); try { return await tools.run(name); } finally { const done = cues.thinking(false); if (done !== null) await session.express(done); } } ``` While thinking is on, `cue()` returns `null` for everything: without that suppression a transcript chunk arriving mid-tool makes the avatar look finished while it is still working. Only a transition acts, so setting the same state twice sends nothing and a retry around the tool is harmless. Leaving sends `neutral` when thinking is still the last thing the call was told to wear, because the model may say nothing at all after a tool result, and with no transcript to re-infer from the face would stick mid-thought for the rest of the call. Call it from a `finally` so a tool that threw still leaves the face behind it. The lexicon is **English only**, and that is a decision rather than an oversight. An Arabic reply, or a reply in any other language, infers `neutral`. Neutral is always a safe face, and a silent guess at the language would put a confident wrong one on the tile. Note the asymmetry before you go looking for a bug that is not there: the viseme table in the next sections **is** bilingual. The mouth moves on an Arabic reply. The face stays neutral. ## The viseme timeline ```ts theme={null} interface SpeechMark { readonly tMs: number; readonly visemeId: number; } ``` One mark is milliseconds from the start of the utterance, and which mouth shape to hold. `tMs` counts from the start of **that turn's audio**, never from the moment the message reaches the service. This is the one place the two SDKs differ in shape. A mark is an **object** in TypeScript and a **tuple** in Python, where `SpeechMark` is declared as `tuple[int, int]` and the same timeline is written `[(0, 12), (200, 4)]`. Everything else here matches: the same ids, the same ordering rules, the same wire payload. Porting a plugin across rewrites the mark literals and nothing else. `session.sendSpeechMarks(marks)` sends the timeline for one utterance. `speechMarks(marks)` is the builder underneath, again exported for a plugin assembling its own wire messages. Both take any `Iterable`, so an array or a generator are equally welcome. ```ts theme={null} import { MAX_VISEME_ID } from "@komaa/standin-sdk"; MAX_VISEME_ID; // 21 ``` Visemes use the Azure Speech numbering, 0 to 21, which is what the avatar expects. The builder **sorts the marks ascending and drops anything outside that range**, along with any mark timed before zero, rather than passing it on. The avatar reads the timeline in order, so a mark out of sequence or a shape it cannot render does not cost one frame: it desynchronises the mouth for the rest of the utterance. ## Measured beats still, guessed loses to still This is the rule that decides whether to send a timeline at all, and it is worth getting right before writing any of the code below. Send a timeline whenever it is spread over a duration you **measured**: the audio your worker actually sent for that turn, or real per-character timings from the speech provider. That is better than a still mouth, and on a realtime path it is the difference between an avatar that talks and one that never opens its mouth at all. Do not send a timeline spread over a duration **guessed** from text length or a words-per-minute rate. That is worse than sending nothing. The error in a guess compounds sentence after sentence, so the mouth drifts further from the voice the longer the call runs, and a mouth moving against the voice reads as broken in a way a still mouth never does. The distinction is the **duration**, not the phonemes. The mouth shapes are approximate either way. ## A timeline from the text and the audio you sent Every realtime speech-to-speech model streams voice and hands back no phoneme timings, so there is nothing for `sendSpeechMarks` to carry and the mouth never moves on the default path. `TurnLipSync` closes that gap the one honest way open to a worker: it counts the audio the turn actually sent, byte by byte, and spreads the text over that measured length. ```ts theme={null} import { TurnLipSync } from "@komaa/standin-sdk"; const lipsync = new TurnLipSync(); // the audio sink for await (const chunk of modelAudio) { await session.sendAudio(chunk); lipsync.audioSent(chunk); } async function onTranscript(text: string, isFinal: boolean): Promise { if (isFinal) { // once per turn, never on a partial const marks = lipsync.finish(text); if (marks.length > 0) await session.sendSpeechMarks(marks); } } async function onBargeIn(): Promise { lipsync.cancel(); } ``` `audioSent(pcm)` takes a PCM16 mono buffer. The constructor's `sampleRateHz` option defaults to the wire's own 16 kHz, which is what a plugin sending frames to the call is holding; pass the real rate when your sink counts at another one, as in `new TurnLipSync({ sampleRateHz: 24_000 })`. For a sink that hands over encoded audio, `audioSentMs(ms)` adds a duration directly, and it ignores a chunk that measures as zero, as negative, or as no number at all rather than taking the turn's count with it. Each chunk is rounded as it is added rather than kept as a running float, so both SDKs accumulate the same integer for the same stream of chunks. `finish(text)` returns the timeline and resets the counter, so the next turn starts from zero. It returns an empty array when no audio was sent or the text carries no mouth shape, and you send nothing in that case. Emit once per turn, on the final transcript: a partial would send an ever-lengthening timeline several times over and the avatar would restart the mouth mid-sentence. `cancel()` is not optional. On a barge-in the service drops audio the caller never heard, and a counter that keeps those milliseconds spreads the next turn's text over its own audio **plus the discarded audio**. The mouth runs long for the whole of that turn and every turn after it. Call it wherever you call [`cancelPlayback()`](/typescript-sdk/call-handler#cancelplayback-is-the-barge-in). Underneath it, and usable on their own: | Name | What it is | | ----------------------------------- | ----------------------------------------------------------------------- | | `estimateVisemes(text, durationMs)` | The spread itself. Returns `SpeechMark[]`, strictly increasing in time. | | `visemeForChar(ch)` | The mouth shape a character wears, or `undefined`. | | `CHAR_VISEMES` | The character to shape map, frozen. | | `SILENCE_VISEME` | `0`, a closed mouth, and what the space between two words becomes. | `estimateVisemes` returns an empty array rather than throwing on anything it cannot use: no text, a duration that is zero, negative, infinite or not a number, or text with no mouth shape in it at all. `"3.5%"` is the last case, and an empty answer is the right one: mapping digits and punctuation to silence instead would punch a hole of closed-mouth frames into the middle of a spoken number. Only a space becomes silence. A run of one shape is one mark, timed at the first character of the run, because "mmm" is one mouth position and a mark per character would multiply the payload for an identical rendering. Times come back strictly increasing, and that is not cosmetic either. Spread a long sentence over a very short buffer and the step falls below half a millisecond, so neighbouring marks round onto the same one. The later shape wins, because a shape held for zero milliseconds is not renderable, and because `speechMarks` re-sorts by `(tMs, visemeId)` and would otherwise hand the avatar a different winner than the one the walk ended on. ### What the table covers `CHAR_VISEMES` covers **all 26 Latin letters and all 28 Arabic letters** in one map, with the eight Arabic variant forms and the three short vowels alongside them: | Viseme | Latin | Arabic | | ------ | --------------------------- | ------------------------ | | 0 | the space between two words | the same | | 2 | `a` | `ا أ إ آ ى ة`, and fatha | | 4 | `e` | | | 6 | `i y` | `ي ئ`, and kasra | | 7 | `u w` | `و`, and damma | | 8 | `o` | | | 12 | `h` | `ه ح ع ء ؤ` | | 13 | `r` | `ر` | | 14 | `l` | `ل` | | 15 | `s z x` | `س ص ز` | | 16 | `j` | `ش ج` | | 18 | `f v` | `ف` | | 19 | `t d n` | `ت د ن ط ض ث ذ ظ` | | 20 | `k g c q` | `ك ق غ خ` | | 21 | `m b p` | `م ب` | Full coverage is not a nice-to-have. One common letter left out thins the timeline unevenly and the mouth stalls on that syllable, and without the Arabic rows at all an Arabic reply produces no tokens, carries no timeline, and the mouth simply does not move for half the people who will be on these calls. The stretch and doubling marks, sukun, shadda, tanween, the tatweel and every presentation form are deliberately absent, because they carry no mouth shape of their own and mapping them would insert mouth changes nobody spoke. `visemeForChar` lowercases and looks the character up as written, with **no Unicode normalization at all**. Folding presentation forms onto their base letter would change which characters map and the two SDKs would then disagree about the same string. A test reads the table out of the Python source and asserts the two are equal character for character, because a mouth that moves differently in two SDKs is a bug a customer finds by switching language rather than one CI finds by itself. The map is frozen and a write to it throws. It is also built with no prototype, so `visemeForChar("constructor")` and `visemeForChar("__proto__")` answer `undefined` like any other unmapped input instead of handing back an inherited function where the signature promises a viseme id. ## Real timings when your provider has them When the speech provider returns per-character timings, use them. They are strictly better than an estimate and they cost nothing, since the synthesis call already produced them: ```ts theme={null} import { estimateVisemes, visemesFromAlignment } from "@komaa/standin-sdk"; let marks = visemesFromAlignment( result.characters, result.characterStartTimesSeconds, ); if (marks.length === 0) marks = estimateVisemes(text, lipsync.durationMs); ``` Core takes two plain arrays: the characters as the provider spoke them, and when each one starts in seconds. Normalising a vendor's field names is the speech plugin's job, not this function's. It is deliberately forgiving of the shapes providers actually return. Ragged arrays are walked to the shorter of the two, a timing that is not a finite number costs its own mark and nothing else, and a time before the start of the utterance is clamped to zero. Providers do return mismatched lengths and bare `NaN` values, and throwing there would lose the turn over a cosmetic hint. Fall back to `estimateVisemes` on an **empty result**, as above, rather than on a missing alignment. A provider that returned timings for punctuation only has an alignment and still needs the estimate. ## When to send the timeline Where the duration is known before playback, which is a text-to-speech path, send the timeline **ahead of the first audio frame**. The service then has the whole mouth plan before the voice starts. On a realtime path the duration is only known once the turn ends, so the marks necessarily go out **after the last chunk was handed over**. That is correct while the service still holds that turn's audio buffered for playout, which is the normal case, and it is why the marks are worth sending at all on a path that would otherwise never move the mouth. ## Your own video on the tile When your agent already produces video of its own (an avatar worker, a rendered face, a camera), that video can go on the tile instead of StandIn's own avatar. `session.sendTileFrame(jpeg, width, height)` puts one frame there. The SDK owns the sequence number and stamps the frame with the **outbound audio timeline**, `session.mediaTimeMs`, the same clock the voice is stamped on, so the two streams cannot disagree about what time it is. A wall clock keeps ticking through listening silence while the audio clock does not, so a wall-clock stamp would make the two look like they are drifting apart when they are in step. Sending frames is the easy part. Sending them at a rate that does not hurt the call is what `TileStream` owns: ```ts theme={null} import { TileStream } from "@komaa/standin-sdk"; const tile = new TileStream(session); await tile.start(); // ... tile.offerRgb(rgb, width, height); // as often as you like // ... await tile.aclose(); ``` | Constructor argument | Default | Meaning | | -------------------------- | ------------ | ------------------------------------------------------------- | | `session` | *(required)* | The `CallSession` the frames go to. | | `options.fps` | `12` | Clamped to 1 through `MAX_TILE_FPS`. | | `options.encoder` | `undefined` | Yours, or sharp is found for you. | | `options.maxBufferedBytes` | `327680` | The backpressure budget, in bytes of unflushed outbound data. | Everything but `session` arrives in one optional `TileStreamOptions`, so `new TileStream(session)` and `new TileStream(session, { fps: 20 })` are both complete calls. ```ts theme={null} import { MAX_TILE_FPS, TILE_HEIGHT, TILE_WIDTH } from "@komaa/standin-sdk"; MAX_TILE_FPS; // 20 TILE_WIDTH; // 640 TILE_HEIGHT; // 360 ``` Frames are encoded to 640 by 360, because shipping an avatar's native resolution only spends bandwidth on pixels the tile will not show. `MAX_TILE_FPS` is a sender-side sanity clamp rather than a protocol limit: a talking head gains nothing above 20, and a higher rate only spends local CPU on encoding and base64. **Latest wins, and each frame goes at most once.** Frames are offered into a single slot, never a queue. A ticker takes whatever is newest and sends it. So a source producing faster than the wire drops the middle frames rather than falling behind, and a source that stops producing goes quiet rather than repeating one stale frame forever. Silence is how a stream ends. That is right for video and would be wrong for audio. A dropped frame is a frame the caller never notices missing. A dropped audio buffer is a hole in a sentence, which is why the audio path buffers what it cannot send yet and this path throws it away. **Video yields to voice.** `TileStream` watches `session.bufferedBytes` and drops a frame rather than adding to it once the budget is exceeded, and it re-checks after an encode resolves, because audio may have filled the socket while the encoder was off the loop. The default budget of 320 KiB is deliberately tighter than the audio buffer cap of `MAX_AUDIO_BUFFER_BYTES`. Both streams share one socket, and a caller forgives a dropped frame far more readily than a break in the voice, so a loaded path goes quiet promptly rather than building up seconds of skew. `framesSent` and `framesDropped` are both readable, and a healthy call has some drops. `offerRgb` and `offerJpeg` never block and never throw. They are meant to be called from a decode loop that must not be slowed down by the wire. `offerJpeg(jpeg)` skips the encoder entirely, for a source that hands you JPEG already: nothing is re-encoded, no encoder needs to be installed at all, and the width and height default to the tile's own 640 by 360. `aclose()` is safe to call twice and on every teardown path. ### The encoder Encoding packed RGB to JPEG needs [sharp](https://sharp.pixelplumbing.com/), which is an **optional peer dependency** of the package rather than a dependency: ```bash theme={null} npm install sharp ``` Pass your own `encoder` to skip it. An `Encoder` is any function taking packed RGB plus a width and a height and returning a promise of JPEG bytes: ```ts theme={null} type Encoder = (rgb: Buffer, width: number, height: number) => Promise; ``` The promise is a divergence from the Python twin, where an encoder is a plain synchronous callable and the stream hands it to a worker thread. Here the yielding is yours to do: the ticker awaits what you return, so an encoder that spends its time on the loop rather than off it holds up everything else the call is doing, the voice included. `jpegEncoder()` is what `start()` calls when you pass none. It resolves sharp at runtime and returns an `Encoder`, or `undefined` having logged why. Call it yourself if you would rather branch on the result before building the stream. Without sharp and without an encoder of your own, the tile relay stays off with one line in the log and **the call is unaffected**. The caller hears your agent and sees StandIn's rendered avatar, which is what they would have seen anyway. sharp is optional precisely because most deployments never put their own video on the tile, and a dependency in every install is the wrong trade for a feature a minority uses. Note that `offerJpeg` still works in that state: it is only `offerRgb` that needs an encoder. ## Next The other half of the video lane: what the caller shows your agent. Giving a model the `express` tool, and the rest of the built-in surface. # Call handler Source: https://docs.komaa.com/typescript-sdk/call-handler The seven optional methods a StandIn TypeScript plugin implements, and the CallSession it is handed. A handler is a plain object with up to seven optional methods. One instance is built per call by your `handlerFactory`. There is no base class, no registration, and no method you are obliged to write: the server treats a missing one as a no-op, so a handler that only wants audio implements only `onCallerAudio`. ```ts theme={null} import { CallServer, type CallSession } from "@komaa/standin-sdk"; class MyHandler { #call: CallSession | undefined; async onStart(session: CallSession) { this.#call = session; } async onCallerAudio(pcm: Buffer) { await this.#call?.sendAudio(pcm); } async onContext(text: string) {} async onGoodbye(text: string) {} async aclose(reason: string) {} } const server = new CallServer({ handlerFactory: () => new MyHandler() }); await server.start(); ``` ## The seven methods `onStart`, `onCallerAudio`, `onVideoFrame`, `onSpeakerChange`, `onContext`, `onGoodbye` and `aclose`. In Python they are the same methods in snake\_case: `on_start`, `on_caller_audio`, `on_video_frame`, `on_speaker_change`, `on_context`, `on_goodbye`, `aclose`. Porting a handler between the languages changes the names and nothing else. One real difference, and it only matters if you are porting: all seven are optional members of one `CallHandler` interface here, while Python keeps `on_video_frame` and `on_speaker_change` on separate `VideoHandler` and `SpeakerHandler` protocols. Python's `CallHandler` is `runtime_checkable`, and a runtime check demands every member, so adding a sixth would have broken `isinstance` for every handler written before the vision lane existed. Nothing about the wire or the behaviour differs. ### `onStart(session)` The call is live. Join a room, open a realtime socket, build an agent, whatever your framework needs. Caller audio does not flow until this resolves, so a slow start delays the caller rather than dropping frames. It is bounded by `onStartTimeoutMs`, 15 seconds by default, because `onStart` does real network work and the frame loop is queued behind it. An unbounded one would hold a call slot for the life of the worker. Caller audio and context that arrive while your provider is still connecting are **yours to hold**, not the server's. `StartupBuffer` is the piece that does it, and losing that window is the difference between an agent that opens by answering the question it was asked and one that opens by asking it again. See [Realtime providers](/typescript-sdk/realtime-providers#what-arrives-before-your-agent-is-ready). ### `onCallerAudio(pcm)` One frame of the caller's voice: PCM16, 16 kHz, mono, little-endian, as a `Buffer`. This runs on the receive path of a live call, so it must not block. The frame is already validated: a truncated or malformed payload is dropped by the server and never reaches here. ### `onVideoFrame(frame)` One sampled frame of the caller's camera or screen share. Frames arrive sparsely and best-effort, and most handlers never implement this: the common shape is to look only when the model asks, which `session.latestVideoFrame()` already serves without a callback. Implement it for ambient vision: narrating a slide deck, watching a whiteboard, noticing that the share stopped. It runs on the receive path too, so a slow model call belongs off the frame loop exactly as in `onCallerAudio`. See [Vision and the avatar](/typescript-sdk/vision). ### `onSpeakerChange(name)` A different person started speaking, and only when StandIn sends unmixed audio. Most calls carry mixed audio and never call it at all. It is called on CHANGE only, never per frame: the name rides every inbound audio frame, and a model told forty times a second who is speaking would hear nothing else. ### `onContext(text)` Non-interrupting context about the call, as a plain sentence ready to put in front of a model: participant counts and group-call etiquette, DTMF key presses, and recording status changes. For example, `The caller pressed the "5" key on their keypad.` Both SDKs publish the same sentences, so an agent written against either reads identical context. Context is delivered as it arrives, and the server does not queue it on your behalf, because what "ready" means is your framework's business. If your agent cannot accept context before it is built, queue it here. ### `onGoodbye(text)` StandIn is ending the call and wants this line spoken first. The server has already told StandIn to drop whatever agent audio it had buffered, so the line plays immediately. Interrupt the current turn and say it. Teardown follows within seconds, and a goodbye queued behind a long answer is a goodbye the caller never hears. The same callback carries the line from a `maxCallMs` ceiling, which is why a plugin that honours `onGoodbye` needs no extra code for the duration limit. ### `aclose(reason)` Release everything this call holds. Always called exactly once, on every path, before the slot is freed. It runs before the socket closes, so a handler that wants to say something on the way out still can. | Reason | What happened | | ----------------------- | ---------------------------------------------------------------------------------- | | `call-ended` | StandIn ended the call normally. | | `pre-start-timeout` | The socket authenticated and never sent `session.start`. | | `caller-idle-timeout` | No caller audio for `audioIdleTimeoutMs`. | | `handler-start-timeout` | `onStart` ran past `onStartTimeoutMs`. | | `handler-start-failure` | `onStart` threw. | | `handler-failure` | Some other handler method rejected. | | `transport-failure` | The frame loop failed. | | `server-shutdown` | `server.aclose()`. | | `no-agent-answered` | Nothing sent audio and nothing called `markAnswered()` inside `staleCallReaperMs`. | | `call-duration-limit` | The call hit `maxCallMs`. | | `agent-ended-call` | The model called the `end_call` tool. | | `outbound-no-answer` | An outbound leg rang out. See [Reaching people](/typescript-sdk/reaching-people). | Anything else is a string you passed to `session.end()`. `no-agent-answered` is the one a plugin author meets first and has nowhere else to look up: it means the call connected and authenticated perfectly and no agent ever arrived. ## The session object `CallSession` is handed to `onStart` and is valid until the call ends. The server owns the socket; this is your only way to reach it. Seventeen members, and this is all of them. | Member | Purpose | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `callId` | StandIn's id for this call. Authenticated: it is the value the handshake HMAC signed, not something a caller supplied. | | `start` | The `session.start` that opened the call: caller identity, direction, thread id, recording status. | | `await sendAudio(pcm)` | Send the agent's voice. Same PCM16 16 kHz mono format `onCallerAudio` receives. Resolving is not proof it went out: see below. | | `await cancelPlayback()` | Drop whatever agent audio StandIn still has buffered. | | `await end(reason)` | Ask for the call to end. Idempotent, and the first reason wins. | | `recordingActive` | Whether the call is being recorded, right now. | | `speaker` | Who is speaking, when StandIn sends unmixed audio. `undefined` on the mixed path. | | `participantCount` | How many people are on the call. Zero until StandIn says. | | `answered` | Whether anything has actually taken this call yet. | | `markAnswered()` | Say that an agent has taken it. Stamped once, never re-stamped. | | `bufferedBytes` | Outbound bytes the socket has not flushed yet. | | `mediaTimeMs` | The outbound audio timeline, in milliseconds. | | `latestVideoFrame(source?)` | The most recent frame the caller showed, or `undefined`. | | `await displayImage(image, options?)` | Draw an image on the bot's video tile for a few seconds. | | `await sendTileFrame(jpeg, width?, height?)` | Put one frame of continuous video on the tile. | | `await express(emotion)` | Hint the emotion the avatar's face should wear. | | `await sendSpeechMarks(marks)` | Send one utterance's viseme timeline, which drives lip-sync. | Five of those repay a paragraph each. **`sendAudio` can drop the frame you hand it.** Past 1 MB of unflushed socket buffer it advances the outbound timeline and returns **without sending**, so the promise resolving is not evidence the audio reached anyone. The caller hears a gap rather than the loop wedging, and the timeline still advances because a dropped frame is a gap in what they hear rather than a rewind. Control frames are never shed. [Audio](/typescript-sdk/audio#agent-audio-can-be-dropped) has the whole rule. **`markAnswered()` is what keeps a listening plugin alive.** A call counts as answered when the handler sends audio. A plugin that joins a room and only listens never sends any, so the [stale-call reaper](/typescript-sdk/call-server#the-watchdogs) ends it after two minutes with `no-agent-answered`. Call `markAnswered()` when your agent's own audio track appears, not when a participant connects: monitors, recorders and avatar workers all connect, and none of them is an agent answering. **`mediaTimeMs` is the clock, not the wall clock.** It is the same timeline this call's audio frames are stamped with, and it is what a video frame must be stamped with too. A wall clock keeps ticking through listening silence while this one does not, so stamping video from a wall clock makes audio and video drift apart on paper even when they are in step. **`bufferedBytes` is evidence, not proof.** It is the number a continuous sender watches before deciding to drop a frame rather than queue it. It reads zero when the transport cannot report it, so treat a zero as "no evidence of backpressure" rather than as an idle socket. **`recordingActive` is one flag the server keeps current**, so no plugin re-derives it from the context sentence it happened to see. A reported `recording.status` **wins over the `session.start` snapshot**, whichever arrives first: the snapshot omits the field when the state was unknown at answer time, and an omitted field is not "not recording". Gate on `recordingActive` before anything that STORES what the caller said or showed with a third party. A recorded call is one the caller was told is being kept; an unrecorded one is not. The last four rows are the vision and avatar lane. StandIn draws the tile; these send it what to draw and what face to wear. [Vision and the avatar](/typescript-sdk/vision) covers what the caller shows you, and [The avatar](/typescript-sdk/avatar) covers expressions, the viseme timeline and putting your own video on the tile. You never track a sequence number or a timestamp. The server owns both, which is why a handler that swaps or re-publishes its audio source cannot make the outbound timeline jump backwards while the sequence number keeps climbing. ## `cancelPlayback()` is the barge-in `cancelPlayback()` is the **only** wire-level lever that un-sends audio already handed to the service. Without it, a barge-in stops the model but the bot keeps talking for the length of the buffered PCM, which is the "it kept talking over me" complaint in its entirety. Call it the moment your provider reports the caller started speaking, and call it **before** you cancel the response upstream. Cancelling upstream first stops new audio being generated but leaves everything already queued at the service to play out. ```ts theme={null} async onProviderSaysCallerStartedSpeaking() { await this.#call?.cancelPlayback(); // first: flush what the service still holds this.#provider.cancelResponse(); // then: stop the model generating more this.#aligner.reset(); // and drop the residual of the interrupted turn } ``` The two natural trigger sites are the provider truncating its own turn because it heard the caller, and a deterministic verbal interrupt you match yourself. The second exists because the model is mid-generation when "stop" arrives, so matching the phrase in code is what makes the cut feel instant. `isVerbalInterrupt` is that matcher, on [Group calls](/typescript-sdk/group-calls#stop-means-stop). ## Refusing a call `session.end()` is safe to call from inside `onStart`. It returns immediately there rather than deadlocking against teardown, and the close runs once `onStart` unwinds. Refusing a call is an ordinary thing to do, so it is one line: ```ts theme={null} import { isInboundCallAllowed, type CallSession } from "@komaa/standin-sdk"; async onStart(session: CallSession) { if (!isInboundCallAllowed(policy, allowFrom, session.start.caller.aadId)) { await session.end("not-allowed"); return; } // ... build the agent } ``` Give the refusal a real reason string. It reaches StandIn as the close reason, so `busy`, `not-allowed` and `realtime-unavailable` are visible on the call instead of appearing as silence. The matcher and the reason it has an id branch as well as a phone branch are on [Security](/typescript-sdk/security#who-may-call-the-agent). It is TypeScript only: a Python worker writes the same test by hand. ## Ordering and failure * **Nothing arrives before `onStart` resolves.** Inbound messages are handled serially, so audio packed into the same TCP read as `session.start` still waits. * **Context can arrive before there is a handler** on a fast dial, and is dropped in that window. There is nothing to receive it, and the server does not buffer on a plugin's behalf. The commoner case is different and is yours: a handler that exists with a provider not connected yet, which is what [`StartupBuffer`](/typescript-sdk/realtime-providers#what-arrives-before-your-agent-is-ready) is for. * **A rejection from any method ends that call alone.** It is logged, the call closes with `handler-failure`, and the worker keeps taking other calls. One bad call must never take the worker with it. ## What `session.start` carries ```ts theme={null} interface Caller { readonly aadId?: string; readonly displayName?: string; readonly tenantId?: string; } interface SessionStart { readonly callId: string; readonly threadId: string; readonly caller: Caller; readonly direction: "inbound" | "outbound"; readonly recordingStatus?: string; readonly tenantId?: string; } ``` Both types are exported, so `import { type Caller, type SessionStart } from "@komaa/standin-sdk"` resolves and you can name them in your own signatures rather than re-declaring the shape. Blank or absent values normalize to `undefined`, so treat every caller field as optional. A `direction` that is neither `inbound` nor `outbound` reads as `inbound` rather than being carried through as a third value every branch has to handle. `caller.aadId` is empty for guest and anonymous callers, which means an anonymous caller can never match an allowlist. That is the intended behaviour, not a gap. Never use it as a bare key for per-caller memory without checking it first either, or every anonymous caller shares one identity. The two `tenantId` fields are not the same tenant and the names invite the mistake. The top-level one is the call's, which is the tenant your worker is bound to. `caller.tenantId` describes the organisation a guest came from, and reaching for it when you meant the other is the one plausible-looking source that is actively wrong. See [Meeting recap](/typescript-sdk/minutes#where-the-minutes-go), where posting to the wrong one is the failure it prevents. ## Next Options, watchdogs, capacity and draining. Rates, frames, and the clipping trap. What arrives before you are ready, and why the agent answers itself. Let the model hang up, show a picture and look at the screen share. # Call server Source: https://docs.komaa.com/typescript-sdk/call-server CallServer options, watchdogs, capacity, draining and teardown in the StandIn TypeScript SDK. `CallServer` answers the socket StandIn dials and hands each call to a [handler](/typescript-sdk/call-handler). It is the same server in both SDKs, method for method, and behavioural parity is asserted by the shared conformance vectors. ```ts theme={null} import { CallServer } from "@komaa/standin-sdk"; const server = new CallServer({ handlerFactory: () => new MyHandler() }); await server.start(); // ... later await server.aclose(); ``` ## What it owns Every item below is framework-independent, which is exactly why it lives here and not in a plugin: * the HMAC handshake, its freshness window and its single-use replay guard * capacity, draining, and the one-live-session-per-`callId` rule * the frame loop and the wire protocol * outbound sequence numbers and the audio timeline, which the video tile is stamped from too * [five timers](#the-watchdogs): the pre-start watchdog, the caller-audio idle watchdog, the `onStart` timeout, the stale-call reaper and the optional call-duration ceiling * idempotent teardown that always frees the slot ## Options Every option falls back to an environment variable, then to a default. | Option | Environment | Default | Meaning | | -------------------- | ----------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `handlerFactory` | none | required | Builds one handler per call. Called with no arguments, so a plugin closes over its own config. | | `secret` | `STANDIN_SECRET` | required | The connection secret from the portal. Must byte-match or the handshake is rejected with 401. | | `host` | `STANDIN_HOST` | `0.0.0.0` | Bind address. | | `port` | `STANDIN_PORT` | `9442` | Port. | | `wsPath` | `STANDIN_WS_PATH` | `/msteams/calling` | Path StandIn dials. | | `maxConnections` | none | `64` | Concurrent live calls, checked before any crypto runs. | | `preStartTimeoutMs` | none | `10000` | How long an authenticated socket may stay silent before it is dropped. | | `audioIdleTimeoutMs` | none | `45000` | Time without caller audio before a live call is declared dead. `0` disables. | | `onStartTimeoutMs` | none | `15000` | How long `onStart` may take before the call is given up. `0` disables. | | `staleCallReaperMs` | none | `120000` | How long a call may run with nothing having answered it. `0` disables. | | `maxCallMs` | none | `0` | Hard ceiling on one call, from `session.start`. `0` disables, so there is no ceiling unless you set one. | | `goodbyeText` | none | `"We are out of time on this call, so I have to stop here. Goodbye."` | Said through `onGoodbye` before the ceiling ends the call. | | `goodbyeGraceMs` | none | `6000` | How long that line is given to finish. | | `onCallOutcome` | none | unset | Called with `(callId, outcome)` when StandIn reports how an outbound call ended with nobody answering. Setting it mounts one extra route; see [Health and routes](#health-and-routes). | The default bind is `0.0.0.0` because a worker usually runs in a container behind an ingress. Bind `127.0.0.1` when only a local tunnel should reach the listener. The upgrade is HMAC-authenticated either way, so this is defence in depth rather than the authentication itself. `CallServer` throws a `StandInError` at construction when no secret is available. Starting without one would mean either refusing every call or accepting anything, and neither is a decision a library should make quietly. `maxCallMs` is the only per-call cost ceiling in the SDK, and it is **off** by default. Nothing else ends a call that is still going: the idle watchdog is satisfied while the caller keeps talking, and a provider billed by the minute keeps billing. Set it on any deployment that pays per minute. **The units differ between the SDKs, and the names differ with them.** TypeScript takes milliseconds (`preStartTimeoutMs`, `maxCallMs`, `goodbyeGraceMs`, `staleCallReaperMs`); Python takes seconds (`pre_start_timeout`, `max_call_seconds`, `goodbye_grace`, `stale_call_reaper_seconds`). There is no `maxCallSeconds` in this SDK. The compiler catches a straight port; a configuration file copied between a Python worker and a TypeScript one does not, and `45` where `45000` was meant disables nothing, it just ends every call in 45 milliseconds. ## Properties and lifecycle | Member | Purpose | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `await start()` | Bind the listener. Transactional: either it is listening when this resolves, or nothing of it survives. Throws if it is already running. | | `await aclose()` | Stop the reaper, close every live call with `server-shutdown`, then stop listening. Awaits the calls' real teardown rather than their close promises. | | `draining` | Set `true` to refuse new calls with 503 while live ones continue. | | `activeCalls` | Live call count. | | `running` | Whether the listener is actually bound. | | `host` | The interface the listener is bound to, or will be. | | `port` | The bound port, resolved after `start()` when you passed `0`. | | `wsPath` | The normalized path, always with a leading slash and no trailing one. | `running` exists because a host that calls connect twice binds a second listener and leaks the first, and without it there is no way to ask whether the bind succeeded, so a dead worker reports itself as connected. `aclose()` ends live calls rather than waiting for them, so a graceful restart is `draining` first and `aclose()` after. It waits for real teardown rather than returning on an in-flight closer, because a close that early-returns lets the process exit with teardown still pending, leaking sessions. The seven timing and goodbye members (`preStartTimeoutMs`, `audioIdleTimeoutMs`, `onStartTimeoutMs`, `maxCallMs`, `goodbyeText`, `goodbyeGraceMs`, `staleCallReaperMs`) are readable but `readonly`: set them in the constructor. Six of the seven are plain public attributes in Python, assignable after construction, and `pre_start_timeout` is private there as it is here. So a Python worker that retunes `audio_idle_timeout` on a running server has no equivalent move in TypeScript, which is one of the few places the two SDKs genuinely differ. ## Health and routes The listener answers `GET /healthz` with `{"ok": true, "calls": }`. One more route exists, and only if you asked for it. Set `onCallOutcome` and the server mounts `POST /outcome/`; leave it unset and that path is a 404 like everything else, so a worker that never places a call opens no extra surface. That is the point of it being optional rather than always on. It sits **under** `wsPath`, so the `/msteams/calling` mount you already have covers it and there is no second route to publish. See [Expose your agent](/expose#mount-the-call-path). When it is mounted: * it is signed with the **v2** request signature over the method, path and a hash of the body, and verified inside the same 60 second window as the handshake * the body is read and capped at 8 KB **before** the signature is checked, because v2 signs a hash of the body and there is nothing to verify until the bytes are in hand. The cap is what stops that being a way to make the worker read an unbounded request from an unauthenticated peer * over the cap is 413, a bad signature or a stale timestamp is 401, and a good one is 204 Your callback throwing is logged and still acknowledged, because a plugin that fails to handle an outcome must not make StandIn retry it forever. Everything else that is not the WebSocket path is a 404. ## Draining A clean rolling restart is two steps: set `draining`, then wait for `activeCalls` to reach zero. ```ts theme={null} process.once("SIGTERM", async () => { server.draining = true; // new calls get 503; live calls continue while (server.activeCalls > 0) { await new Promise((r) => setTimeout(r, 1000)); } await server.aclose(); }); ``` ## Admission order The upgrade is checked in this order, and the order matters: 1. **Path.** Anything outside `/` is 404. A malformed URL or percent escape is 400 rather than an exception, because this runs before authentication and must never take the process down. 2. **Draining.** 503, so a worker winding down does not accept calls it will never serve. 3. **Capacity.** 503 at `maxConnections`, checked **before any crypto**, so a flood cannot make the worker spend CPU on signatures for calls it was never going to accept. 4. **Signature.** 401 unless the HMAC verifies inside the 60 second window. 5. **Replay.** 401 when that exact handshake has already been used once. 6. **Duplicate call.** 409 when that `callId` already has a live session. ## The watchdogs Five timers can end a call. Three bound a call that has not started properly, one bounds a call nothing ever answered, and one bounds a call that is going perfectly well and will not stop. A socket that authenticates and then sends nothing holds a call slot. After `preStartTimeoutMs` the call is closed with `pre-start-timeout`. A live Microsoft Teams call delivers PCM continuously, because silence is still frames. Audio going quiet for `audioIdleTimeoutMs` therefore means the call is gone and nobody said so. That happens: the peer keeps the socket open, and even keeps pinging, while its own teardown is wedged. The call closes with `caller-idle-timeout`. The watchdog is armed **before** `onStart`, not after. While `onStart` is awaited the frame loop is queued behind it, so `session.end` is never read. Armed after, a hung `onStart` would have no watchdog at all and the `callId` would 409 forever, leaking one slot per inbound call. `onStart` does real network work and holds the frame loop. After `onStartTimeoutMs` the call closes with `handler-start-timeout`, separately from `handler-start-failure`, so a third-party outage inside a plugin is not reported to StandIn as StandIn's own socket failing. Sending both sides to debug the wrong system is its own kind of outage. The gap none of the other three cover. `session.start` arrived, so the pre-start watchdog is satisfied. `onStart` returned, so its timeout is satisfied. The caller is still talking, so the idle watchdog is satisfied. And yet no agent is on the call: an agent dispatch that never landed is the commonest misconfiguration there is, and the caller sits listening to nothing. The reaper ends such a call after `staleCallReaperMs`, 120 seconds by default, with the reason `no-agent-answered`. It is on by default because it spends nothing and only frees what is already lost. A call counts as answered the moment the handler sends audio. A plugin that joins a room and only listens sends none, so it must say so itself: ```ts theme={null} session.markAnswered(); ``` Call it when your agent's own audio track appears, not when a participant connects: monitors, recorders and avatar workers all connect, and none of them is an agent answering. `session.answered` tells you whether anything has. Off unless you set `maxCallMs`. A caller who will not hang up, a model looping at itself, an automated system that dialled and never stopped talking: none of them trips a silence check, and all of them bill by the minute for as long as the socket lives. On the limit the server flushes playback first, then delivers `goodbyeText` through your handler's `onGoodbye`, waits `goodbyeGraceMs`, and closes with `call-duration-limit`. The goodbye goes through the same callback StandIn's own closing line uses, so a plugin needs no new code to honour it, and the grace is bounded whatever the handler does with it: a plugin that hangs in `onGoodbye` must not turn a time-limited call into an endless one. ## Teardown Teardown is idempotent and the **first** reason wins, because a cascade of close causes must not overwrite the one that actually ended the call. Every caller awaits the same promise. The order is fixed: cancel the watchdogs, wait for an in-flight `onStart` to unwind (bounded), dispatch your `aclose`, write the advisory `session.end`, close the socket with a 2 second bound, then release the slot unconditionally. The release is in a `finally`, so whatever failed above, the `callId` becomes usable again. ## Logging The SDK logs to the console by default, because a library that silently swallows "this worker takes no Microsoft Teams calls" is a library people debug for an hour. Route it into your own logger: ```ts theme={null} import { setLogger } from "@komaa/standin-sdk"; setLogger({ debug: (m) => myLogger.debug(m), info: (m) => myLogger.info(m), warn: (m) => myLogger.warn(m), error: (m) => myLogger.error(m), }); ``` This is the one place the two SDKs differ on logging. Python has a standard library logging tree to hang on, so it logs through the logger named `standin` and has no `set_logger` at all; TypeScript has no such tree, so it exports this function instead. Ids that appear in logs are rendered log-safe first: control characters are replaced and length is bounded, so a crafted `callId` cannot forge a log line. ## Limits A single inbound message is bounded at 2 MB, matching the Python SDK. Outbound agent audio is dropped rather than queued past 1 MB of unflushed socket buffer (`MAX_AUDIO_BUFFER_BYTES`), because a wedged peer turns "send everything" into an unbounded queue. Control frames are never shed: a call that cannot be ended is the failure that shedding exists to prevent. Frame types the SDK does not know are ignored by contract, so an older plugin and a newer StandIn interoperate rather than failing. The honest example is a frame type added to the protocol after your plugin was published: the avatar and vision frames are **not** an example, because this SDK sends and parses them. See [Vision and the avatar](/typescript-sdk/vision). ## Next The seven methods, the session object, and how to refuse a call. Every `STANDIN_` variable the SDK reads, and what reads it. Ring your own handler on loopback before anyone places a real call. The HMAC lanes, the replay guard, and who may call the agent. # Call tools Source: https://docs.komaa.com/typescript-sdk/call-tools One set of call capabilities, declared in every provider's own JSON, in the StandIn TypeScript SDK. A voice model can talk. What it cannot do, unless you tell it, is hang up, put a picture on its own video tile, react with an expression, or look at what the caller is showing. Those are properties of being on a Microsoft Teams call, not of any provider, so they live in the SDK. Every plugin gets the same set, and a caller gets the same agent whichever provider answers. ## The capabilities | Tool | Parameters | What it does | | ------------ | ----------------------------- | ---------------------------------------------------------------- | | `end_call` | none | Hang up. | | `express` | `emotion` | Show an emotion on the avatar's face. | | `show_image` | `url`, `caption?`, `display?` | Put a picture on the bot's video tile. | | `look` | `source?`, `question?` | Look at the caller's camera or screen share. | | `look_back` | `question?` | Look again at something already moved past. Recorded calls only. | `display` is `"fullscreen"` or `"overlay"`, and it is a schema `enum` rather than a list written into the description, because a model obeys a schema enum far more reliably than the same choice spelled out in prose. [Vision and the avatar](/typescript-sdk/vision#fullscreen-or-beside-your-face) covers which to reach for. The descriptions are written **for a model**. They say when to reach for a tool rather than what the code does, because that sentence is the only thing the model reads before deciding. `show_page` is deliberately **not** in that list. It needs a renderer, and a deployment with none would still be telling every model it can show a web page, promising the caller and then apologising on every call. An absent tool is honest; a broken one is not. A plugin that does have a renderer registers `SHOW_PAGE_TOOL` itself. ## More tools the SDK ships None of these are built in either, for the same reason: an agent with nothing behind a tool should not be told it has one. Each is a `ToolSpec` ready to `register`. | Tool spec | What it needs behind it | Page | | -------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `SHOW_PAGE_TOOL` | A page renderer on `VisionTools`. | [Vision and the avatar](/typescript-sdk/vision#showing-a-web-page) | | `CONSULT_TOOL` | A `Consultant`, so the slow agent can be asked while the caller waits. | [Consulting](/typescript-sdk/consulting) | | `BACKGROUND_TASK_TOOL` | A `BackgroundTasks` store, so a promise outlives the call. | [Consulting](/typescript-sdk/consulting) | | `MINUTES_TOOL` | A resolved minutes target, because whether there is anywhere to post depends on the call. | [Meeting recap](/typescript-sdk/minutes) | | `CALL_BACK_TOOL`, `CHAT_CALLBACK_TOOL` | An outbound lane and its allowlist. | [Reaching people](/typescript-sdk/reaching-people) | ## Declaring them Every provider invented its own JSON for "here is a function you may call", so `schemas` renders one list in the shape yours wants. ```ts theme={null} import { CallTools, VisionTools } from "@komaa/standin-sdk"; const tools = new CallTools(session, { vision: new VisionTools(session, { describer }) }); agent.declare(tools.schemas("flat")); ``` `vision` is the only option, and it is optional: leave it out and `CallTools` builds a `VisionTools` of its own. Pass one when you want to give it a describer, a renderer or a budget, which is nearly always, because a `VisionTools` with no describer can put pictures up but cannot say what it sees. The instance is on `tools.vision` afterwards either way. | Dialect | Shape | Who takes it | | ----------- | ----------------------------------- | --------------------------------------------- | | `flat` | `{name, description, parameters}` | Deepgram's Settings, ElevenLabs' client tools | | `openai` | the same, tagged `type: "function"` | the Realtime API | | `anthropic` | `{name, description, input_schema}` | the Messages API | An unknown dialect falls back to `flat` rather than throwing. A tool a model never sees is a worse outcome than a shape one provider happens to also accept, and a crash at connect time helps nobody. ## Running them Translate your provider's tool-call frame into a name and an object, and hand it over: ```ts theme={null} const result = await tools.dispatch(name, params); // never throws ``` **Dispatch never throws.** It returns a sentence, because the result goes back to a model that will read it out. "I could not show that because the image was too large" is worth something to a model; a stack trace is not. When your provider's tool-result frame also carries an error flag, use `run` instead: ```ts theme={null} const outcome = await tools.run(name, params); agent.sendResult(callId, outcome.text, !outcome.ok); ``` `ok` is false only when the SDK **knows** the tool did not do what it was asked: a missing or malformed argument, a rejected value, a handler that threw, or a name no tool answers. It is not a verdict on the vision tools, which answer in sentences by design, so "there is nothing to look at" comes back as ok with the reason in the text. ## Tools of your own Your own tools go in the same place, so the model sees one list: ```ts theme={null} tools.register( { name: "open_ticket", description: "Use this when the caller reports a fault that needs following up.", parameters: { summary: { type: "string", description: "What is wrong, in one line." } }, required: ["summary"], }, openTicket, ); ``` The handler may return a string or a promise of one, and what it returns is what the model is told. Keep it fast: the caller is listening to silence while it runs. A tool of your own may not shadow a built-in. That is refused when you register it rather than at the first call, because a shadowed `end_call` is an agent that has quietly lost the ability to hang up, and that is not something to discover mid-conversation. The provider plugins already do all of this. Reach for `CallTools` when you are writing a plugin of your own, or adding capabilities to a handler you wrote yourself. ## Whether to answer at all A tool decides what an agent can do. In a meeting, the prior question is whether it should say anything at all, and the SDK ships that separately: `GroupGate` and the wake-phrase matching around it. It is inert on a 1:1 call, so a plugin that never configures a phrase behaves exactly as it did before. See [Group calls](/typescript-sdk/group-calls). ## Next What `look`, `look_back` and `show_image` actually do. What `express` puts on the face, and the lip-sync beside it. The two tools that hand real work to a second agent. The session every one of these tools is bound to. # Chat Source: https://docs.komaa.com/typescript-sdk/chat Answer Microsoft Teams messages from your Node.js agent with ChatChannel, without holding a Bot Framework credential. The messages lane puts your agent in Microsoft Teams chat. StandIn owns the Microsoft Teams bot, authenticates the activity, resolves it to your connection and strips the bot @mention. Your handler returns text and StandIn performs the Microsoft Teams send. Two consequences follow, and they are the reason this lane exists in the shape it does: * **Your agent never holds a Bot Framework credential.** There is nothing to register, rotate or leak. * **The worker dials out.** The socket is opened from your side, exactly like the call lane, so there is no listener, no port to expose and no tunnel. ```mermaid theme={null} flowchart TD T["Microsoft Teams message"] --> G["StandIn
authenticates, normalizes, signs"] G -->|"pushed down the worker's outbound socket"| C["ChatChannel"] C -->|"your async handler returns reply text"| G G --> T2["StandIn sends it to Microsoft Teams"] ``` This is the twin of the Python SDK's `standin.chat`, method for method, with this language's casing. The shared conformance vectors assert both parse and build identical wire payloads. One behaviour genuinely differs, on shutdown, and it is called out below. ## The whole thing ```ts theme={null} import { ChatChannel } from "@komaa/standin-sdk"; const chat = new ChatChannel({ respond: async (message) => `You said: ${message.text}`, }); await chat.start(); ``` `ChatChannel` throws a `StandInError` at construction when no secret is available. Managed connections only, and that needs no flag: the socket authenticates with your connection secret, so if it opens at all you are on the StandIn AI Teammate app. A meeting recap that only posts uses `listenOnly: true` so it never answers. ## Meeting recap `listenOnly: true` is how a recap lane posts without answering. The host must supply that outbound chat lane (`chat.start()`) and a summarization callback (`postMinutes` or the plugin consult). Hang-up does not await the post. Meeting recaps are best-effort. They may be lost if the worker exits during processing. ## Options | Option | Default | Meaning | | ------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `respond` | required | Async callable taking an `InboundMessage` and returning the reply text. An empty string makes the channel say so rather than leaving the user watching a typing indicator forever. | | `secret` | `STANDIN_CHAT_SECRET`, then `STANDIN_SECRET` | The key this lane signs its dial with. | | `url` | `STANDIN_CHAT_URL`, then `wss://teams.standin.komaa.com/api/chat/channel` | The chat channel endpoint. | | `chats` | none | A [`PersonalChats`](#remembering-who-messaged-you) to feed on every inbound message, so a 1:1 call has somewhere to post to. | | `listenOnly` | `false` | Take messages without answering them. `chats` is still fed, `respond` is never called, and nothing is sent back, not even the typing indicator. For a lane that only posts, such as a meeting recap, when something else already answers this connection's chat. | ### Which secret A StandIn AI Teammate app connection can be issued a **separate key for chat**. When it is, set `STANDIN_CHAT_SECRET` and it wins; with one key for both lanes, set only `STANDIN_SECRET` and this lane falls back to it. A key scoped to one lane cannot be used on the other, which is the point of issuing two. **Unset the variable, never blank it.** TypeScript stops at the first variable that is merely present, so `STANDIN_CHAT_SECRET=""` is taken as the chat secret, is empty, and the constructor throws even though `STANDIN_SECRET` is set and correct. The Python twin uses a truthiness fallback and quietly falls through to `STANDIN_SECRET` instead, so the same deployment file works there and fails here. Worse, the error names `STANDIN_SECRET` rather than the variable that actually caused it, so the message sends you to the wrong place. Unset it and the two SDKs behave alike. Every variable is listed on [Configuration](/typescript-sdk/configuration). ## Shutting down ```ts theme={null} await chat.aclose(); ``` **The two SDKs differ here, and it is a silent data-loss difference.** TypeScript lets in-flight turns settle before closing, so a reply already handed to your agent is delivered on shutdown. Python cancels them. A Python worker that cares about those replies should stop taking new messages and let the turns finish before calling `aclose`; a TypeScript worker does not need to, but should expect `aclose` to take as long as its slowest turn has left to run. ## `InboundMessage` One user message, already authenticated and resolved to your connection. ```ts theme={null} interface InboundMessage { readonly tenantId: string; readonly conversationId: string; readonly activityId: string; readonly scope: string; // "personal" for 1:1, otherwise group or channel readonly text: string; readonly senderName?: string; readonly senderAadId?: string; readonly senderIsGuest: boolean; readonly senderIsLinkedOwner: boolean; readonly attachments: Record[]; readonly mentions: string[]; readonly locale?: string; readonly cardAction?: Record; readonly bindingId?: string; } ``` Reserved bot commands are handled by StandIn and never arrive here. In group and channel scope only messages that @mention the bot are relayed, and the mention is already stripped from `text`. `cardAction` carries the submit payload of an `Action.Submit` on a card your agent sent, and `text` is empty on those messages. `bindingId` says which StandIn connection this conversation resolved to. One tenant can have several connections, so the tenant alone no longer identifies who a reply is from. You never set it: read it if you need it, and let `buildReply` echo it. `attachments` is raw. A pasted screenshot, a dragged-in file or a voice note all arrive here as plain objects, and reading a JSON blob to a model is the worst thing you can do with one. `buildChatTurn` turns the whole message into something to hand a model in one call: see [Attachments in chat](/typescript-sdk/chat-attachments). `isPersonal(message)` is true when the message came from a 1:1 chat rather than a group or channel. It is a free function here and a property on the message in Python, `message.is_personal`, so this is one of the few lines a port has to rewrite rather than rename. Use it to decide how chatty to be: ```ts theme={null} import { ChatChannel, isPersonal } from "@komaa/standin-sdk"; const chat = new ChatChannel({ respond: async (message) => { if (!isPersonal(message)) { return `In a group, briefly: ${await agent.ask(message.text)}`; } return agent.ask(message.text); }, }); ``` ## `parseInbound` and `buildReply` Use these when you are relaying messages yourself rather than through `ChatChannel`, for example behind your own HTTP endpoint. ```ts theme={null} import { buildReply, parseInbound } from "@komaa/standin-sdk"; const message = parseInbound(rawBody); // throws StandInError naming the problem, map to 400 const reply = buildReply(message, "Sure, here is that report."); ``` The exception type differs between the SDKs: this one throws `StandInError`, the Python twin raises `ValueError`. Code mapping the failure to HTTP 400 catches a different class in each. `buildReply` echoes `tenantId` and `conversationId` **exactly**. StandIn rejects a mismatch, and that check is the cross-tenant leak guard the whole relay rests on. Never rewrite either field, never construct a reply from a conversation id you obtained anywhere except the inbound message you are answering. `buildReply` also sets `replyToId` from the inbound `activityId`, derives an `idempotencyKey` of `{activityId}:{kind}`, and echoes `bindingId` when the inbound message carried one. It takes four arguments, not three: ```ts theme={null} buildReply(message, text, kind?, image?) ``` `kind` defaults to `message`; `typing` and `error` are the other two the channel itself sends, and a `typing` reply carries neither text nor image, because it is a state rather than a message. `image` is an `OutboundImage` rendered inline in the reply. Build one with `outboundImage`, which checks the declared type against the file's real magic bytes, caps it at 1 MB and refuses `image/svg+xml` by design: see [Sending a picture](/typescript-sdk/sending-pictures). That page also covers the `MEDIA:` marker convention, which is how an agent that has written a file gets it sent: `parseMedia` strips a `MEDIA:/path` line out of the reply text so a caller is never read a temp path aloud, and `loadMedia` turns one into bytes only under the directories `STANDIN_MEDIA_ROOTS` names. That gate defaults to **none**, because an agent can be talked into writing `MEDIA:/etc/passwd`, and it is the kind of thing an operator turns on too widely. `parseInbound` requires `tenantId`, `conversationId` and `activityId` to be non-empty strings. It rejects a `schemaVersion` above `SCHEMA_VERSION`, which is currently `1`. That version is a **major** version: additive evolution does not bump it, because the schema already requires receivers to ignore unknown fields, so an integer above yours means incompatible semantics rather than new fields. An unknown `scope` is relayed as a group chat rather than rejected, because the scope is an open enum. ## Authentication The chat dial signs the literal channel name `"chat"` with `signHandshake`, in the `X-StandIn-Timestamp` and `X-StandIn-Signature` headers. That is the inbound call handshake running in the opposite direction: same scheme, same 60 second window, the worker signing instead of verifying. ```ts theme={null} import { SIGNATURE_HEADER, TIMESTAMP_HEADER, nowMs, signHandshake } from "@komaa/standin-sdk"; const timestamp = nowMs(); const headers = { [TIMESTAMP_HEADER]: String(timestamp), [SIGNATURE_HEADER]: signHandshake(secret, timestamp, "chat"), }; ``` `ChatChannel` does this for you. You only need it if you are dialing the channel yourself. The POST relay lane signs the **body** instead, with `signBody` and a 300 second window. Both lanes are correct and they are not interchangeable. Never "fix" one into the other. See [Security](/typescript-sdk/security). ## What the channel does for you Turns are chained per `{tenantId}:{conversationId}`. The schema promises per-conversation ordering, and independent tasks would let replies overtake each other. A failed turn does not dam the chain. StandIn delivers at least once. A redelivery of the same activity must not start a second turn, so the channel keeps a bounded LRU of seen keys. An aged-out redelivery running again is acceptable at-least-once behaviour; a fresh double-run is not. A redelivery is still remembered by `PersonalChats` before the dedupe runs, because it is still evidence that this person has a chat with this bot, and remembering it twice changes nothing. A typing indicator is sent before your handler runs, not after, so the indicator still lands first while the agent thinks. If your handler returns an empty string the channel says so rather than leaving the user watching a typing indicator forever, and a thrown error becomes a short apology. After a typing indicator, silence looks exactly like a hang. One turn may run for five minutes before it is abandoned. Serialization means a hung turn would wedge its conversation forever. The bound is generous because agent turns legitimately run long. A timeout sends an error reply rather than silence, because after a typing indicator silence looks exactly like a hang. The bound is a module constant here and cannot be changed from outside. The Python twin holds the same five minutes in a public class attribute, `ChatChannel.TURN_TIMEOUT_S`, which a deployment can raise or lower. ## Remembering who messaged you A 1:1 **call** carries no thread and no conversation to post into. So when a caller asks for the minutes, or for anything else in writing, there is nowhere obvious to send it. The only honest source of that person's own chat is a message they sent the bot. `PersonalChats` is that memory. Feed it from the chat lane, read it from the call lane: ```ts theme={null} import { ChatChannel, PersonalChats } from "@komaa/standin-sdk"; const chats = new PersonalChats(); const chat = new ChatChannel({ respond, chats }); // later, on a call: const mine = chats.forCaller({ callerAadId: session.start.caller.aadId, tenantId: configuredTenant, }); ``` Four rules decide whether a chat is admissible, and posting call content into the wrong conversation is the failure all four exist to prevent: * **It was a 1:1 chat**, decided by the message's scope and by nothing else. An @mention in a team channel would otherwise make that channel the caller's "chat" and put a private escalation in front of their whole team. Testing the id prefix instead inverts the rule exactly: a personal chat's id begins `a:1...` while `19:...` is precisely the group and channel shape, so a prefix test rejects every real personal chat and admits nothing useful. * **It is in this tenant**, the one the worker is bound to, never the caller's own. * **It was seen inside `CHAT_FALLBACK_WINDOW_MS`**, 12 hours: long enough to cover a working day, short enough that a conversation from last week is not treated as evidence of who is on the phone today. * **The call names a caller whose AAD id is the one that sent it.** Without this last rule every anonymous caller collapses onto whoever chatted last. At most 512 people are remembered, keyed by tenant and directory id, oldest evicted first, because a tenant with many users must not grow without bound inside a worker that is also carrying live audio. `PersonalChat` is the record itself: `conversationId`, `tenantId`, `aadId`, `displayName` and `atMs`. A message is remembered **before** the at-least-once dedupe runs, but the dedupe still governs the turn: a redelivery is more evidence that this person has a chat with this bot, and remembering it twice changes nothing. Both lanes have to run in **one process** for this to work, or the memory has to be shared some other way. `PersonalChats` is in-memory: across processes, a caller who has not messaged the bot inside the window simply has no personal target. `forCaller` takes an `allowUnidentified` escape hatch, off by default, for a single-operator install where the only person who ever chats with the bot is the only person who ever calls it. With it on, an unidentified caller's minutes go to whoever messaged this bot last. Every use logs a warning naming that, and it should stay off anywhere a second person exists. The options object's type, `ForCallerOptions`, is not re-exported from the package barrel today, so pass the argument inline rather than trying to name its type. `resolveMinutesTarget` on [Meeting recap](/typescript-sdk/minutes) is what actually consumes this. ## Posting without an inbound message `chat.send()` posts into a Microsoft Teams conversation with nothing to answer, which is useful from inside a call: ```ts theme={null} await chat.send({ tenantId: session.start.tenantId ?? "", conversationId: threadId, text: "Here is the summary you asked for on the call.", idempotencyKey: `${session.callId}:summary`, }); ``` It also takes `image` and `bindingId`. It is best-effort and returns `false` rather than throwing, because a failed post must never break a live call. A `false` here means the socket was not open or the send threw, and nothing more: the socket carries no status back, so a post StandIn later refuses still returns `true`. Pass an `idempotencyKey` whenever a retry is possible. ## Next Screenshots, dragged-in files and voice notes, as one `ChatTurn`. `outboundImage`, and `MEDIA:` markers with their filesystem gate. Where a 1:1 call's minutes are allowed to go. The handshake this lane signs, and the body lane it is not. # Attachments in chat Source: https://docs.komaa.com/typescript-sdk/chat-attachments Turning a pasted screenshot, a dragged-in file or a voice note into one ChatTurn your agent can answer, in the StandIn TypeScript SDK. A Microsoft Teams message can carry a pasted screenshot, a file dragged in from disk, or a voice note. Without any of this, your handler gets `InboundMessage.attachments` as raw objects, so the best it can do is read a JSON blob to a model and the worst is answer a message about a picture as though nothing had been sent. ## One call does the whole message ```ts theme={null} import { buildChatTurn, chatImageDataUrl } from "@komaa/standin-sdk"; const turn = await buildChatTurn(message, { transcribe: myStt }); const answer = await agent.ask(turn.query, turn.images.map(chatImageDataUrl)); ``` `buildChatTurn` takes the `InboundMessage` your [chat handler](/typescript-sdk/chat) was given and returns a `ChatTurn`: the text to ask, the pictures to hand over, and a plain sentence naming anything that came with the message. | Field | Type | What it is | | ---------------- | ------------- | ------------------------------------------------------------------ | | `query` | `string` | The text to put in front of the model, including every note below. | | `images` | `ChatImage[]` | Pictures that were fetched and are ready for a vision model. | | `voiceNote` | `string` | What the voice notes said, or empty. | | `attachmentNote` | `string` | The "what was attached" sentence, on its own. | Everything but the message arrives in one `ChatTurnOptions` object, and every field of it is optional: ```ts theme={null} const turn = await buildChatTurn(message, { origin: undefined, // leave it off to derive it; see the origin pin below images: true, // false to skip image fetching entirely transcribe: undefined, // your speech-to-text function, or no transcription }); ``` `images: false` and no `transcribe` make it a pure text assembler that opens no sockets at all. A handler that fetches nothing pays nothing. There is a fourth option, `fetchFn`, which substitutes the `fetch` every request goes through: it exists so a test can reach every edge of this page without a socket. This lives in `attachments.ts`, deliberately **not** in `chat.ts`. That module owns the socket, the duplicate check and the per-conversation ordering, and none of that changes here. Fetching is optional work that must never be able to wedge the transport, so it sits beside it rather than inside it. Everything on this page is re-exported at the package root, with exactly one exception, and that exception is under [Images](#images) below. ## The order is the order a person would say it in `query` is assembled in a fixed order, and the order is not arbitrary: it is the order somebody would have said the same thing out loud. 1. **What they typed.** `message.text`, trimmed. 2. **What they pressed.** The submit payload of an `Action.Submit` on a card this agent sent, as `cardActionNote`. A card message arrives with **empty text**, so without this the agent is asked nothing at all and answers as though the person said nothing. The payload is serialized and truncated at `CARD_PAYLOAD_MAX_CHARS`, which is 4096 characters, and that bounds this agent's own card template rather than a stranger's message. 3. **What they said out loud.** The transcribed voice notes, under `[They sent a voice message]`. 4. **What they attached.** The note from `attachmentsNote`, under `[Attached to this message]`. Each part is omitted when it is empty, and the parts are joined by blank lines. A message with nothing attached produces a `query` that is exactly the text the person typed. ## It never throws `buildChatTurn` does not throw. Anything that will not load is **named in the note** instead of failing the turn, because a failed picture is not a reason to leave a question unanswered. The note distinguishes the two outcomes per attachment: ```text theme={null} [Attached to this message] - plan.png [image] (attached) - q3.xlsx [file] (unreadable) ``` That distinction is the point of the note. A model told a picture was attached and could not be opened says something useful. A model told nothing answers as if the message were empty. And a model told a picture was attached, when nothing was actually fetched, answers **about a picture it has never seen**, which is the worst of the three. The note is bounded at `ATTACHMENT_NOTE_MAX_LINES` entries, which is ten, followed by a count of the rest, so a message with forty attachments does not become the whole prompt. `attachmentsNote(attachments, status)` is callable on its own, where `status` is a `Map` keyed by the attachment's index in the message. Both that bound and `CARD_PAYLOAD_MAX_CHARS` above are exported from the package root here. The Python twin keeps its two out of its public surface entirely, so that page spells the numbers rather than naming a constant to import. ## Images ```ts theme={null} import type { ChatImage } from "@komaa/standin-sdk"; ``` | Field | Type | What it is | | ------------ | --------------------- | --------------------------------------------------------------- | | `dataBase64` | `string` | The image as it arrived, base64. | | `mime` | `string` | The real media type. The response's own header wins; see below. | | `name` | `string \| undefined` | The filename, when there was one. | | `sizeBytes` | `number` | How big it was. | `ChatImage` is an interface, and an interface carries no accessors, so the two conveniences the Python twin exposes as properties are plain functions here: ```ts theme={null} import { chatImageData, chatImageDataUrl } from "@komaa/standin-sdk"; chatImageData(image); // Buffer, for an API that uploads a file chatImageDataUrl(image); // "data:image/png;base64,..." for most vision APIs ``` `fetchChatImages(attachments, { origin })` is the piece underneath, for a handler that wants the pictures without the rest of the turn. It is best-effort per attachment: one that will not load costs that attachment, never the answer. The caps are at the package root, with one missing on purpose: ```ts theme={null} import { IMAGE_FETCH_ATTEMPTS, // 8 IMAGE_FETCH_TIMEOUT_MS, // 10_000 MAX_IMAGES, // 4 } from "@komaa/standin-sdk"; ``` `MAX_IMAGES` is the **accept** cap: four pictures are kept from one message, because each becomes a base64 blob in front of a model. `IMAGE_FETCH_TIMEOUT_MS` is how long one image has to arrive, **in milliseconds**. The Python twin spells the same two budgets in seconds, as `IMAGE_FETCH_TIMEOUT_S` and `CLIP_FETCH_TIMEOUT_S`, so a number carried across unchanged is out by a factor of a thousand. Override either cap on a `fetchChatImages` call with its `maxImages` and `timeoutMs` options. `IMAGE_FETCH_ATTEMPTS` is a **separate** cap, and it is the one that keeps the worst case arithmetic. The accept cap counts only successes, so without it a message naming fifty attachments that all time out still costs fifty timeouts and blows the whole turn budget while nothing is ever accepted. The attempt cap counts requests whatever the outcome: eight tries at ten seconds, and that is the ceiling. The one number you cannot import is how much one image may weigh. It is 4 MiB, it is the default for the `maxBytes` option, and it matches the per-attachment ceiling StandIn applies anyway, so a larger local number could never be reached. `MAX_IMAGE_BYTES` **is** exported from the package root, and it is **not** this cap. The root one comes from `vision.ts` and is `1_400_000`: the **outbound** limit on an image you draw on the bot's video tile. The inbound attachment cap is a different number in a different direction of travel, and it keeps its own `MAX_IMAGE_BYTES` inside `attachments.ts` rather than colliding with the other at the root. Two consequences. Passing the root `MAX_IMAGE_BYTES` as `fetchChatImages`'s `maxBytes` compiles cleanly, is a perfectly real number, and quietly caps inbound attachments at 1.4 MB instead of 4 MiB. And there is no deep import that reaches the other one: the package's `exports` map lists the root and the plugin subpaths only, so `@komaa/standin-sdk/attachments` does not resolve. Leave `maxBytes` unset to take the 4 MiB default, or pass a number of your own. `buildChatTurn` never takes one at all, so a turn assembled through it always uses the default. See [Vision](/typescript-sdk/vision#fetching-an-image-a-model-chose) for the outbound one. Two more things the fetch does, both about what arrives rather than what was claimed. The media type is judged **before a byte is read**, and the response's own header wins, because an error page would otherwise be base64'd in front of a model as though it were a picture. What the message declared is the fallback, and only when it is itself a media type: a dragged-in file declares a bare extension, and taking that literally would fail every `image/` and `audio/` check. And the size cap holds **while** the body is read rather than after it, because a `content-length` that lies, or is simply absent, otherwise gets to allocate whatever it likes before a later check objects. ## A dragged-in picture is a file, not an image A pasted screenshot arrives with `kind` of `image`. The **same file** dragged in from disk arrives with `kind` of `file` and a `contentType` that is a bare extension like `png` rather than a media type. So gating on the declared kind alone is the trap here, and it is a quiet one: the picture is never fetched, but the note still says one was attached, and the model answers about a picture nobody gave it. An attachment is therefore worth a request when its kind matches **or** when its kind is exactly `file` and its filename's extension, or a declared type that is itself a bare extension, is one of `png`, `jpg`, `jpeg`, `gif`, `webp`, `bmp`, `heic`, `heif`. The widening is `file` only: it exists to rescue the dragged-in case, not to override a kind the message stated outright. What the file then turns out to be is decided by the response, not by the extension that got it tried. An attachment StandIn marked as not relayable is skipped without a request. ## Voice notes ```ts theme={null} import type { ChatAudio, Transcriber } from "@komaa/standin-sdk"; ``` `ChatAudio` is one clip before anything has transcribed it: `data` as a `Buffer`, `mime`, and `name`. `fetchChatAudio` returns them. `video/` types are accepted as well as `audio/`, because some clients label a voice note with a container type that speech-to-text reads perfectly well, and the same extension widening applies: a voice note relays as a file in practice, so a plugin gating on `kind === "audio"` transcribes nothing, ever. `Transcriber` is the function **you** supply: ```ts theme={null} type Transcriber = (data: Buffer, mime: string) => Promise; ``` The core ships none and reads no provider key. Deciding which speech vendor somebody's voice is sent to is not a decision an SDK gets to make on their behalf, and a default would make it silently. ```ts theme={null} import { transcribeVoiceMessages } from "@komaa/standin-sdk"; const said = await transcribeVoiceMessages(attachments, { origin, transcribe: myStt, }); ``` It returns the clips as one block of text, and empty when there are none, when no transcriber was supplied, or when every one failed. With no transcriber it fetches nothing at all rather than downloading audio that has nowhere to go. A transcriber that throws costs that clip and nothing else. The clip budgets are deliberately larger than the image ones, and all four are at the package root: ```ts theme={null} import { CLIP_FETCH_ATTEMPTS, // 4 CLIP_FETCH_TIMEOUT_MS, // 20_000 MAX_CLIPS, // 2 MAX_CLIP_BYTES, // 16 MiB } from "@komaa/standin-sdk"; ``` A voice note is minutes of audio where a picture is one screen, so it needs four times the bytes and twice the wall clock to arrive. The attempt cap is halved to pay for that: four tries at twenty seconds is the same eighty second worst case as eight tries at ten. And two clips rather than four, because each accepted clip is a transcription call on top of the download. ## An engine that only takes a path Plenty of transcription engines will not take bytes. `spoolClip` writes the clip to a temporary file, named with the extension its media type implies, and hands your callback the path: ```ts theme={null} import { spoolClip, type Transcriber } from "@komaa/standin-sdk"; const myStt: Transcriber = async (data, mime) => spoolClip({ data, mime, name: "" }, (path) => engine.transcribeFile(path)); ``` It resolves to whatever your callback resolved to, and it rethrows whatever your callback threw. The file is removed **on the way out, on every path**, including the one where the engine threw. That is not tidiness. A transcription engine is being handed somebody's voice, and a clip left behind in a temporary directory is a copy nobody decided to keep, sitting on disk long after the conversation it came from ended. This is a callback rather than a context manager, which is the shape TypeScript has for "hold this open for exactly this long". The other difference from the Python twin is that this one always spools into the system temporary directory: there is no directory argument here. ## The origin pin Every fetch on this page is pinned to the one origin the messages themselves arrived from: ```ts theme={null} import { chatAttachmentOrigin } from "@komaa/standin-sdk"; chatAttachmentOrigin(); // from STANDIN_CHAT_URL, else the default chatAttachmentOrigin("wss://example.com/api/chat/channel"); // "https://example.com" chatAttachmentOrigin("ws://127.0.0.1:9444/x"); // "http://127.0.0.1:9444" ``` It is derived from the chat channel's own URL rather than configured separately, so it is right by construction for a self-hosted or local setup and there is no second setting to get wrong. `ws` maps to `http` and `wss` to `https`, and a default port is not spelled out, so `wss://host:443` and `wss://host` compare equal. The name is `chatAttachmentOrigin` here and `attachment_origin` in Python. That is the one name on this page that is not a straight casing translation of its twin, so a search for `attachmentOrigin` finds nothing. It **fails closed**. A URL it cannot resolve, and anything that is not `http`, `https`, `ws` or `wss`, returns `undefined`, and `undefined` means fetch nothing at all. That direction is the whole design. An attachment URL is signed, but not by you and not for you: it arrives inside a message somebody else wrote, and the pin is the only thing bounding where this worker can be told to go. An unset origin read as "anywhere" would turn a configuration typo into a fetcher that a stranger's message can point at any address it likes, an internal service or a cloud metadata endpoint included. Two consequences worth knowing. An attachment whose URL is not on that origin is refused **without a request**, so the log line is the only cost. And no redirect is followed: the URL is same-origin and signed, so a redirect off it is already anomalous, and following one would reopen the door the pin just closed, since the pin is checked on the URL in the message and not on wherever a `302` points. Pass `origin` explicitly when your handler already knows it. Otherwise `buildChatTurn` derives it for you. ## Next The channel these messages arrive on, and what goes back out. The signing lanes, and the rest of the fetch posture. # Checking the install Source: https://docs.komaa.com/typescript-sdk/checking-the-install Ring this worker's own handler on loopback and prove audio made the round trip, without a provider bill, a public tunnel or a Microsoft tenant, in the StandIn TypeScript SDK. Every plugin has to answer one question before anybody trusts it with a real call: does this actually work here? Until now the only way to find out was to place a real call, which costs a provider bill, a public tunnel, a Microsoft Teams tenant and somebody's afternoon. `runSmoke` answers it on loopback in under a second. It binds an ephemeral listener, connects a real client that speaks the real call wire, streams a few frames of silence, and reports what came back. ```ts theme={null} import { runSmoke, smokeReport } from "@komaa/standin-sdk"; import { MyHandler } from "./myHandler.js"; const result = await runSmoke(() => new MyHandler()); console.log(smokeReport(result)); process.exit(result.ok ? 0 : 1); ``` `handlerFactory` is the same `() => CallHandler` that [`CallServer`](/typescript-sdk/call-server) takes, called once. It is called with no arguments, so a handler that needs configuration closes over it in the arrow function. The printer is on the package as **`smokeReport`**, not `report`. The module names the function `report` and the package renames it on the way out, so `import { report } from "@komaa/standin-sdk"` does not resolve. `SyntheticCall`, `runSmoke`, `SmokeCheck` and `SmokeResult` keep their own names. ## What it prints ```text theme={null} standin: ok ok secret (generated for this run) ok listener (127.0.0.1:55064/msteams/calling) ok call (10 frames in 235 ms) ok audio (10 frames came back) ``` And when the handler never sent anything back: ```text theme={null} standin: NOT ok ok secret (generated for this run) ok listener (127.0.0.1:55073/msteams/calling) ok call (4 frames in 93 ms) FAIL audio (0 frames came back) without it: the caller would hear nothing ``` Every failed check prints what stops working without it. A report that names a missing surface and not the consequence sends the operator to the source to find out whether it matters. `smokeReport` returns the string rather than printing it, so a plugin can put it in a log line, a tool result or an HTTP response. When the run recorded an `error`, a final ` error: ...` line follows the checks. ## The four checks They run in this order, and each one is the next thing that breaks in the field. | Check | What it proves | What the failure costs | | ---------- | --------------------------------------------------------------------------------- | ------------------------------------ | | `secret` | A credential exists for this run. | nothing could authenticate | | `listener` | The worker bound its socket and has a call path. The detail is the bound address. | no call could ever reach this worker | | `call` | Connect, handshake, `session.start`, the frame loop and hang-up all completed. | a real call would fail the same way | | `audio` | Frames came back out of the handler. | the caller would hear nothing | All four are mandatory, and `result.ok` is true only when every mandatory check passed. **`ok` is never true when nothing echoed.** The whole point of the run is that audio made the round trip, so a run that proved nothing must not read as a pass. A listener that binds, authenticates and then says nothing is exactly the install people ship by accident. The run sends `recording.status: active` before the first frame. Handlers commonly gate their output on the call being recorded, and without it a recording-gated handler stays silent and the run reports a false negative. Frames are PCM16 mono at 16 kHz, 20 ms each, which is `FRAME_BYTES` (640) at the cadence a real call arrives at. `frames` defaults to 10. If the listener cannot bind, the run returns there and then with two checks, a failed `listener` carrying the bind error, and `echoFrames` at 0. There is nothing left to ring. The two results are plain readonly shapes: ```ts theme={null} interface SmokeResult { readonly checks: SmokeCheck[]; readonly echoFrames: number; readonly error?: string; readonly ok: boolean; } interface SmokeCheck { readonly name: string; readonly ok: boolean; readonly detail?: string; readonly cost?: string; readonly required?: boolean; } ``` ## It never borrows your configuration The run generates its own connection secret, `randomBytes(16).toString("hex")`, for that run only. It never reads `STANDIN_SECRET`. Two reasons, and both are real failures rather than tidiness. A fixed string would be a predictable credential on a live listener. The check is meant to be safe to run anywhere, including on a host that is answering calls. Borrowing the operator's secret makes the run pass or fail for reasons that have nothing to do with the wire, and it makes the check impossible to run before the secret is configured. That is exactly when people run it. ## Loopback, on port 0 The listener is built with `host: "127.0.0.1"` and `port: 0`, and the port it actually got is read back from `server.port` after it is listening. Loopback because a verification run has no business being reachable from the network. Port `0` because the alternative is picking a free port in advance with a throwaway socket, which races whoever binds it next. Two runs in the same CI job, or a run next to a real listener, and the loser fails for a reason that has nothing to do with the install. The call path is the one this worker is configured to serve, so the address in the report is the real one a call would arrive on. ## Nothing that delivers is started **This is the most important property on the page.** No pending-message sweep, no no-answer reaper, no outbound caller, no chat lane. Nothing that delivers is started, and the listener's own stale-call reaper is off for the run (`staleCallReaperMs: 0`). A verification command that quietly resumed durable jobs would place real calls and post real messages as a side effect of somebody checking an install. Durable work is durable on purpose. Parked messages, background-task promises and unanswered outbound legs all survive a restart, because the person they are for is still owed the answer. That is correct for a worker coming up, and completely wrong for a check somebody ran to find out whether an install works. `runSmoke` starts a listener and a handler, and nothing else. The listener is closed with `aclose()` in a `finally`, on every path including the failing ones, so a run leaves nothing bound behind it. ## A plugin's own checks `extra` runs after the call and resolves to more checks, so a plugin can prove its own surfaces in the same report. ```ts theme={null} import { runSmoke, type SmokeCheck } from "@komaa/standin-sdk"; async function myChecks(): Promise { return [ { name: "model", ok: await modelReachable(), cost: "the agent could not answer" }, { name: "browser", ok: await browserReachable(), detail: "no display", cost: "show_page would apologise", required: false, }, ]; } const result = await runSmoke(() => new MyHandler(), 10, myChecks); ``` **Mandatory unless declared advisory.** Leave `required` off and the check counts towards `ok`; set `required: false` for anything the call itself survives without. A degraded extra is worth reporting and is not worth failing an install over. Give every check a `cost`. It is the sentence printed under a failure, and it is what tells the operator whether to act now or after lunch. ```text theme={null} FAIL browser (no display) without it: show_page would apologise ``` An `extra` that rejects is recorded, not fatal. The report gains a non-required `plugin checks` entry carrying the error text, because a broken check must not take down the report of the call that did work. ## Pointing one at your own listener `SyntheticCall` is the client `runSmoke` uses. It is exported because a plugin with its own listener may want to point one at that instead. ```ts theme={null} import { SyntheticCall } from "@komaa/standin-sdk"; const callId = "smoke-local"; const call = new SyntheticCall( `ws://127.0.0.1:${port}/msteams/calling/${callId}`, secret, callId, 10, ); await call.run(); console.log(call.echoFrames); ``` `run()` connects, greets, streams, hangs up, and rejects on anything that fails. `echoFrames` is how many audio frames came back. The constructor takes `url`, `secret`, `callId`, then `frames` (10, clamped to at least 1) and `connectTimeoutMs` (5000). Three things it does that a hand-rolled client usually gets wrong: * **The handshake is signed freshly for each connect.** The listener enforces single use inside the freshness window, so a retry that reused the headers would loop on 401 and read as a wrong secret. * **The `callId` in `session.start` is identical to the one in the path.** A start that disagrees with the authenticated path is refused, and the refusal reads as a wire fault. * **The message listener is attached before the socket opens.** An echo that arrives while the run is still sending is counted, and nothing blocks the 20 ms cadence, which would otherwise make the call look idle to the server's watchdogs. A frame it cannot parse is not counted as an echo. The socket is closed in a `finally`, on every path. An error path that skipped the close leaks a socket per run inside a long-lived host that exposes the check. The client lives in the SDK, next to the wire it speaks, deliberately. A copy kept inside a plugin drifts, and the copy that used to exist kept passing against a call path that no longer existed. That is the exact failure a smoke check is for. ## Timeouts The whole run is bounded at `timeoutMs`, 15000 by default, and the connect at 5000. A listener that accepts and then stops talking would otherwise hang CI, and hang a status tool call for ever. A run that times out is a recorded failure, not a hang. The `call` check fails with `the call did not finish within 15000 ms`, and the same sentence becomes `result.error`. ```ts theme={null} function runSmoke( handlerFactory: HandlerFactory, frames?: number, // 10 extra?: () => Promise, timeoutMs?: number, // 15000 ): Promise; ``` ## In your own command Wire it to a subcommand and exit on `result.ok`, so CI fails on a bad install. ```ts theme={null} #!/usr/bin/env node import { runSmoke, smokeReport } from "@komaa/standin-sdk"; import { MyHandler } from "./myHandler.js"; const result = await runSmoke(() => new MyHandler(), 4); console.log(smokeReport(result)); process.exit(result.ok ? 0 : 1); ``` It needs no network, no tenant and no credentials, so it also belongs in the test suite: ```ts theme={null} import { runSmoke } from "@komaa/standin-sdk"; import { expect, it } from "vitest"; import { MyHandler } from "./myHandler.js"; it("answers a call here", async () => { const result = await runSmoke(() => new MyHandler(), 4); expect(result.ok).toBe(true); expect(result.echoFrames).toBeGreaterThan(0); }); ``` Four frames is enough to prove a round trip and keeps the test well under a second. Raise it when your handler needs more audio before it says anything. The Python SDK has the same module, check for check: see [Checking the install](/python-sdk/checking-the-install) for the `run_smoke` form. # Configuration Source: https://docs.komaa.com/typescript-sdk/configuration Every STANDIN_ variable the SDK reads, the helpers for reading your own, and how a missing optional peer surfaces, in the StandIn TypeScript SDK. Everything the SDK reads from the environment is listed here, and nothing else in the package reads a `STANDIN_` variable. Every one of them can be overridden in code, because a constructor option always beats an environment value. **Python and TypeScript do not treat a blank `STANDIN_CHAT_SECRET` or a bad `STANDIN_PORT` the same way.** Unset `STANDIN_CHAT_SECRET` rather than exporting it empty. A non-numeric `STANDIN_PORT` is `NaN` here and a `ValueError` in Python. Details are under [How a value is read](#how-a-value-is-read). The Python twin is [Configuration](/python-sdk/configuration). ## Every variable the SDK reads | Variable | Default | What reads it, and what it does | | ------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `STANDIN_SECRET` | none | Your StandIn connection secret. `CallServer`, `ChatChannel` and `OutboundCaller` all refuse to construct without one. | | `STANDIN_CHAT_SECRET` | falls back to `STANDIN_SECRET` | The key the chat lane signs with, read before `STANDIN_SECRET`. A StandIn AI Teammate app connection issues a separate key for chat; with one key for both lanes, leave this unset. | | `STANDIN_HOST` | `0.0.0.0` | Bind address for the call listener. | | `STANDIN_PORT` | `9442` | Port for the call listener. | | `STANDIN_WS_PATH` | `/msteams/calling` | The path StandIn dials on your worker. | | `STANDIN_CHAT_URL` | `wss://teams.standin.komaa.com/api/chat/channel` | The chat lane endpoint. Also the origin an inbound attachment may be fetched from, derived from this rather than configured twice. | | `STANDIN_WORKER_URL` | none | The StandIn control address `OutboundCaller` posts to, which StandIn gives you with your connection secret. Outbound calling is unavailable until it is set, and a value that is not http or https, has no host, or carries credentials is refused when the caller is built rather than when somebody tries to ring. | | `STANDIN_OUTBOUND_ALLOW` | empty, meaning off | Comma-separated directory ids this agent may ring, read by `OutboundPolicy.fromEnv()`. | | `STANDIN_OUTBOUND_MAX_PER_HOUR` | `6` | Outbound calls allowed in any rolling hour, across all targets. Zero means **no cap**, not "no calls", so do not reach for it to turn outbound off: leave `STANDIN_OUTBOUND_ALLOW` unset for that. | | `STANDIN_TENANT_ID` | none | Which organisation `VoiceDelivery` places calls into. Operator configuration only: never the message, the metadata, the model or the caller. Exported as `TENANT_ENV`. | | `STANDIN_STATE_DIR` | `~/.standin/state` | Where parked outbound messages are kept, and the default parent of unfinished meeting recaps, created owner-only. | | `STANDIN_RECAP_DIR` | `$STANDIN_STATE_DIR/recap` | Where unfinished meeting recaps sit until they post, expire (24 hours), or hit the retry limit (8). Restart-recoverable local spool. Best-effort unless this directory is on storage that survives a process or container restart: the default is local process disk, which is ephemeral in most containers. | | `STANDIN_MEDIA_ROOTS` | empty, meaning off | Directories a `MEDIA:` marker may read a local file from. Exported as `MEDIA_ROOTS_ENV`. | | `STANDIN_VISION_API_URL` | none | The full chat-completions URL `FrameDescriber` posts a frame to, so a voice model can be asked what it is looking at. It is used exactly as written, not as a base to append a path to. | | `STANDIN_VISION_MODEL` | none | The model name for that endpoint. `FrameDescriber.fromEnv()` returns `undefined` unless both this and the URL are set, and that `undefined` is the signal a plugin uses to tell an agent that looking is unavailable here. | | `STANDIN_VISION_API_KEY` | none | Sent to that endpoint as a bearer token when set. A local endpoint usually needs none. | Two of these names are exported as constants rather than written out, so your code and the SDK cannot drift apart: ```ts theme={null} import { MEDIA_ROOTS_ENV, TENANT_ENV } from "@komaa/standin-sdk"; ``` Provider credentials are not in this table. Each plugin reads its own vendor prefix, `OPENAI_`, `ELEVENLABS_`, `DEEPGRAM_`, `CARTESIA_` or `LIVEKIT_`, and each plugin page lists its own. That split is deliberate: a `STANDIN_` variable configures your worker, never the provider you chose to answer calls with. The three `STANDIN_VISION_` variables sit on the line because the endpoint they name is yours to pick, so keep in mind that `STANDIN_VISION_API_KEY` is a credential and belongs wherever the rest of your secrets live. The Python SDK reads the same fifteen, plus one this SDK has no use for. `STANDIN_SHOW_ROOTS` fences the document renderer that Python ships and TypeScript does not, so do not go looking for it here. See [Configuration](/python-sdk/configuration) for that side. ## How a value is read The rule is the same everywhere: an explicit option wins, then the environment, then the documented default. ```ts theme={null} // secret: the option, else STANDIN_SECRET, else refuse to start const server = new CallServer({ handlerFactory, secret: fromYourVault }); ``` Prefer the option whenever the value already exists somewhere better, such as a platform secret store or a host that resolved it for you. An environment variable that silently overrides a value an operator can actually see in a portal is a bad day waiting to happen. Three specifics are worth knowing before they cost you an afternoon: `ChatChannel` reads the option, then `STANDIN_CHAT_SECRET`, then `STANDIN_SECRET`. That chain stops at the first value that is **present**, and an exported empty string is present. So `export STANDIN_CHAT_SECRET=` shadows a perfectly good `STANDIN_SECRET` and the channel throws at construction instead. Unset the variable rather than blanking it. The port is read as `Number(process.env.STANDIN_PORT ?? 9442)`. A value that is not a number becomes `NaN` rather than an error naming the variable, and you find out at `start()`. If you templated that value from somewhere, check it before you pass it. The path is trimmed and its slashes normalized, and a path that reduces to `/` is refused with `wsPath must be a real path such as /msteams/calling`. A worker listening at the root would answer anything that reached it, so this fails at construction rather than at the first call. ## Reading your own configuration Every plugin needs the same things: a value that must be set, one that may be, a boolean, a check that a vendor host is really that vendor's, and a header map. When each plugin wrote its own, the error a user saw for a missing key depended on which provider they happened to pick. These five helpers are the shared version. ```ts theme={null} import { flag, jsonObject, optional, required, vendorHost } from "@komaa/standin-sdk"; ``` | Helper | Signature | Behaviour | | ------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | | `required` | `(name, purpose = "") => string` | Throws `StandInError` naming the variable when it is unset or blank. | | `optional` | `(name, fallback?) => string \| undefined` | Blank reads as absent, so an exported empty value takes the fallback. | | `flag` | `(name, fallback = false) => boolean` | Only the exact string `true` is true. | | `vendorHost` | `(name, fallback, suffix) => string` | Throws unless the host is the bare suffix or ends with it. | | `jsonObject` | `(name) => Record` | Parses a JSON object and coerces every value to a string. Blank is an empty object; a JSON array or number throws. | `purpose` completes the sentence "X is required to ...", so write it as a verb phrase and the message reads properly: ```ts theme={null} const key = required("ACME_API_KEY", "answer calls with Acme"); // ACME_API_KEY is required to answer calls with Acme ``` **`flag` is strict on purpose.** The value is trimmed and lowercased, then compared to the single word `true`, so `TRUE` and `True` work and `1`, `yes`, `on` and `y` are all false. Most flags worth having turn a guard **off**, and a guard must not be disabled by a plausible-looking typo landing in a config file. A reader who wanted it on and typed `1` gets the safe direction and a behaviour they will notice, not a guard quietly removed. An unset variable is not a typo, so it returns the fallback untouched rather than being forced to false. **`vendorHost` is the one worth reading twice.** Your API key travels to whatever host the configuration names, so a mistyped or injected host is not a failed call, it is credential exfiltration. That is why it throws rather than warning: ```ts theme={null} const host = vendorHost("ACME_API_HOST", "api.acme.com", ".acme.com"); // "acme.com" passes, "eu.acme.com" passes, "api.acme.com.evil.test" throws ``` The host is accepted when it equals the suffix with its leading dot stripped, or when it ends with the suffix exactly as you passed it. Pass the suffix **with** its leading dot. `".acme.com"` accepts `api.acme.com` and rejects `evilacme.com`; `"acme.com"` accepts both, because `evilacme.com` ends with it. That is a guard that reads as present and is not. **`jsonObject` never logs the value it rejected.** It is used for header maps, and a header map carries **your** credentials to somebody else's endpoint. The error says the variable must be a JSON object and stops there. These five live at the package barrel in TypeScript, so `import { required } from "@komaa/standin-sdk"` is the whole import. The Python SDK does **not** re-export them: they are `standin.config.required`, `standin.config.optional`, `standin.config.flag`, `standin.config.vendor_host` and `standin.config.json_object`, imported as `from standin.config import required`. Same behaviour, same argument order, different import line. ## Optional peer dependencies The core installs with one runtime dependency, `ws`, and needs Node 20 or newer. That floor is the **core's** floor, not the highest any plugin wants, so a plugin that needs more says so on its own page. Everything else this package can reach is an optional peer, which is what lets one package ship every plugin: the core imports nothing from `plugins/`, so the bare specifier works on a machine with no framework installed at all. ```ts theme={null} import { CallServer } from "@komaa/standin-sdk"; // no peer needed ``` | Peer | Reached by | What its absence does | | -------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@livekit/rtc-node` | `@komaa/standin-sdk/livekit` | `StandInError`, naming both packages and the `npm install` that fixes it. | | `livekit-server-sdk` | `@komaa/standin-sdk/livekit` | The same error: the plugin needs both, so it loads both together. | | `openclaw` | `@komaa/standin-sdk/openclaw` | A bare module-not-found, untranslated: this is the one static import of a peer in the package. The case does not arise, because OpenClaw loads the plugin inside its own process, so the host is always already there. | | `sharp` | the avatar tile relay | A warning, the relay off, and audio unaffected. Frames cannot be encoded, but nothing about the call breaks. | ### How a missing one surfaces LiveKit is loaded at **use** time rather than at module load, so importing the plugin on a machine without LiveKit still succeeds and the failure, when it comes, arrives translated: ```text theme={null} the livekit plugin needs @livekit/rtc-node and livekit-server-sdk: run `npm install @livekit/rtc-node livekit-server-sdk` ``` That translation is the point. A reader who gets `Cannot find module '@livekit/rtc-node'` thrown out of somebody else's package cannot tell a missing optional dependency from a broken install, and will go looking in the wrong place. Naming the install line ends it in one line. `sharp` is the other shape. It is looked for, and when it is not there the SDK warns and carries on: ```text theme={null} standin: the avatar tile relay needs sharp to encode frames (npm install sharp); the relay is off and audio is unaffected ``` The rule behind the difference is that a missing peer throws when it makes a feature impossible and warns when it makes an **optional** feature unavailable. Supply your own `encoder` on `TileStreamOptions` and `sharp` is never looked for at all. This SDK has no dedicated exception for a missing peer, and `StandInError` is narrower than its name suggests. It covers configuration and the wire: the constructors that refuse to start, the five helpers above, `parseInbound`, and the LiveKit peer message, with `OutboundError` extending it for the outbound lane. It does **not** cover everything the SDK throws. The picture, media and fetch helpers, `outboundImage` and `loadMedia` among them, throw a plain `Error`, so an `instanceof StandInError` filter silently drops those. Catch `Error` and narrow inside it. The Python SDK does have a dedicated one, `standin.PluginNotInstalled`, which subclasses both `StandInError` and `ImportError` so that `except ImportError` and `importlib.util.find_spec` keep working. Do not port a `catch (PluginNotInstalled)` from Python code: there is nothing here by that name. The two SDKs also do not have the same optional set, because the frameworks are not the same in each language. TypeScript needs two LiveKit packages and uses `sharp` to encode tile frames. Python reaches LiveKit through one extra, `standin-sdk[livekit]`, encodes the same frames with Pillow from `standin-sdk[tile]`, and carries a `render` extra for the document renderer that has no counterpart here. Read the peers of the SDK you are installing, not the other one's. ## Version ```ts theme={null} import { SCHEMA_VERSION, VERSION } from "@komaa/standin-sdk"; ``` Two numbers, and a bug report wants both. `VERSION` is this package's own version, the same string the Python twin exposes as `standin.__version__`. `SCHEMA_VERSION` is the chat wire schema, versioned separately and bumped only for a breaking change, because the schema already requires a receiver to ignore fields it does not know. Knowing both is what separates "your worker is old" from "the message was wrong". ## Next The listener most of these variables configure. Prove the configuration is right before a real call does. # Consulting and background work Source: https://docs.komaa.com/typescript-sdk/consulting Delegate real work from a live call without stalling it, and keep a promise to deliver later, in the StandIn TypeScript SDK. A voice model has to answer in under a second or the call sounds broken. Real work does not fit in a second. Looking something up, reading a file, driving a browser, running a tool: those take ten seconds, or five minutes. A caller listening to silence cannot tell thinking from crashed. So an agent that does real work is two agents. The fast one talks. The slow one works. This is the seam between them, and it is the same whichever provider does the talking and whichever framework does the working. ## Hold on, let me check `Consultant` runs the slow agent inside a live call. You supply a factory; what it builds is yours. ```ts theme={null} import { Consultant } from "@komaa/standin-sdk"; const consultant = new Consultant(() => (query) => myAgent.run(query), 45_000); const answer = await consultant.ask("what did we bill Contoso last quarter?"); ``` The first argument is a **factory**, not the agent: it is called on first use and again after a timeout, because a timed-out consultation cannot be stopped and whatever state it holds is no longer yours. The second is the timeout in milliseconds, defaulting to `DEFAULT_CONSULT_TIMEOUT_MS`, which is 45 seconds. `ask` takes an optional second argument that overrides it for one question, which is how `resume` below runs the same consultant on a five minute budget instead. Wrap the method, do not pass it bare. `() => myAgent.run` type-checks and then throws at runtime the moment `run` reads anything on `this`, because a method detached from its instance has no instance. The same trap catches the `Poster` on [Meeting recap](/typescript-sdk/minutes) and the `Deliverer` below. Every callable this SDK takes must arrive already bound. Two behaviours are not obvious, and both exist because a caller is listening. **One at a time, and the second asker is told so.** A model that can delegate can delegate twice before the first answer lands. Queueing the second means it waits out both timeouts and answers far too late, so it is refused immediately with a sentence the model can say out loud. **A timeout is admitted.** The work cannot be cancelled: the promise runs to its end whatever this returns. The answer says it stopped rather than promising a follow-up nothing will send, and the timed-out agent is dropped so the next question builds a fresh one. Your asker must not block. Node has one thread, and a synchronous agent holds it for its whole duration, stopping the call's audio along with everything else. Do the work in a promise, a worker, or another process. `ask` never throws. It always returns something speakable, because an exception in a tool result is an agent that goes quiet mid-sentence. ## I'll send you the result When the work will not finish while somebody waits, it becomes a promise, and a promise made on a call that is about to end has to outlive the process that made it. `BackgroundTasks` writes each task to disk **before** the work starts and removes it only once the result has actually been delivered. ```ts theme={null} import { BackgroundTasks } from "@komaa/standin-sdk"; const tasks = new BackgroundTasks(); // during the call const task = tasks.remember(query, session.start.threadId); tasks.begin(task); // ... tasks.done(task); // once, at worker startup await tasks.resume( () => (query) => myAgent.run(query), (threadId, text) => postToChat(threadId, text), ); ``` `resume` returns how many tasks it actually delivered. Its third argument is the per-task timeout, `DEFAULT_TASK_TIMEOUT_MS` or five minutes by default, which is far longer than a consultation's because nobody is on the line waiting for it. `BackgroundTasks` takes `directory`, `ttlMs` and `resumeLimit`. The directory defaults to a `tasks` folder under `STANDIN_STATE_DIR`, or under `~/.standin/state` when that is unset. Deliberately never a temp directory: a temp directory passes every test and loses every parked promise on the next reboot, which is invisible until somebody is waiting for an answer that no longer exists. The two-phase claim is what makes a restart safe. A task waiting to run is a `.json`; a task being run is a `.claimed`. A process that dies mid-run leaves a claim behind, and the next startup takes it back once it is old enough to be sure nobody is still working on it. A delivery that fails puts the task back rather than dropping it. | Guard | Why | | ----------------------- | -------------------------------------------------------------------------------------------------------------------- | | Two hour expiry | An answer long after the question is worse than no answer. | | Five tasks per startup | A restart storm must not fan out one agent per promise across the fleet. What is left stays queued, never discarded. | | No thread, no task | A task with nowhere to deliver cannot be kept, so it is dropped loudly at startup rather than run for nothing. | | Ten minute claim window | Taking a claim somebody is still working on delivers the same answer twice. | ## The two tools Both are `ToolSpec` values, ready to register on `CallTools`. They are not built in, because an agent with no second agent behind it should not be told it has one. ```ts theme={null} import { BACKGROUND_TASK_TOOL, CONSULT_TOOL } from "@komaa/standin-sdk"; tools.register(CONSULT_TOOL, (params) => consultant.ask(String(params.query ?? ""))); tools.register(BACKGROUND_TASK_TOOL, startBackgroundTask); ``` Their descriptions are deliberately distinguishable. Without that, a model picks whichever comes first and every long job is run while somebody waits on the line. Once the background tool is registered, pass `true` as the `Consultant`'s third argument and a timeout will point the caller at it. Leave it off and it will not, because promising a path that does not exist is worse than admitting the work stopped. ## Next Where both tools are registered, beside the built-ins. The other job a `Consultant` is normally handed. When the answer should ring somebody back rather than land in a chat. `STANDIN_STATE_DIR` and every other variable the SDK reads. # Group calls Source: https://docs.komaa.com/typescript-sdk/group-calls GroupGate, wake phrases, the follow-up window and verbal interrupts for an agent in a Microsoft Teams meeting, in the StandIn TypeScript SDK. A 1:1 call is simple: every turn is for you. A meeting is not. People talk to each other, and an agent that answers every turn of a meeting is worse than one that says nothing, because it has to be muted or removed before the meeting can continue. Two decisions follow from that, and both are taken here rather than by your model: * **who gets answered**, so the agent stays out of a conversation it was not invited into * **what stops playback**, so "stop" stops it whether or not the model would have chosen to Both are taken deterministically in code, on a **finished** caller turn, and they consume the same thing: one transcript, once. Neither is left to the model, for the same two reasons. A model asked "was that for you?" has already been invoked, so a refused turn still costs a round trip and a bill. And a model asked to decide whether to keep talking is a model that is still talking while it decides. ```ts theme={null} import { DEFAULT_FOLLOW_UP_WINDOW_MS, GroupGate, isAddressed, isMeetingThread, isVerbalInterrupt, type GateDecision, } from "@komaa/standin-sdk"; ``` Every name on this page is exported from the package root. There is no deep import for the gate module: the package publishes the root and the framework plugin subpaths and nothing else, so `@komaa/standin-sdk` is the import. ## How the SDK knows it is a group call `isMeetingThread(threadId)` is the primary signal, and it is a string test: ```ts theme={null} import { isMeetingThread } from "@komaa/standin-sdk"; isMeetingThread("19:meeting_abc@thread.v2"); // true isMeetingThread("8:orgid:0f2a..."); // false isMeetingThread(""); // false isMeetingThread(undefined); // false ``` Microsoft's thread id for a meeting or a channel conversation begins `19:`. A 1:1 call has no such thread at all, so the presence of one is the signal. It reaches you as `session.start.threadId`, on every call, before the caller has said a word. See [Call handler](/typescript-sdk/call-handler#the-session-object). **A participant count never arrives on the meeting-join path.** A gate keyed on the count alone is dead on exactly the calls it exists for: every meeting the agent is invited to reads as a 1:1, and the agent answers every turn of it. The failure looks like a gate that was never switched on rather than one that was keyed to the wrong field, which is what makes it expensive to find. That is not a hypothetical. The same mistake in the recap path sent every meeting recap to one attendee's private chat instead of to the meeting it summarised, because the count was pinned at 1 there too. See [Meeting recap](/typescript-sdk/minutes). `noteParticipants(count)` is the second signal, and it is deliberately weaker: ```ts theme={null} import { GroupGate, type CallSession } from "@komaa/standin-sdk"; class MyHandler { #call: CallSession | undefined; #gate: GroupGate | undefined; async onStart(session: CallSession) { this.#call = session; this.#gate = new GroupGate({ wakePhrases: ["assistant"], threadId: session.start.threadId, }); } async onContext(text: string) { // CallServer already parsed the count for you; read it, do not scrape it. const count = this.#call?.participantCount ?? 0; if (count > 0) this.#gate?.noteParticipants(count); await this.#sendToModel(text); } } ``` It corroborates and it never overrules. The count is kept as a running maximum, so a count of `1` arriving on a meeting thread adds nothing and takes nothing away: `gate.isGroup` stays true. The count is the signal that goes missing, so it may add certainty and must not remove it. `isGroup` is true when either signal says so: a meeting thread, or a count of two or more. That covers the group call that is not a meeting thread as well as the meeting whose count never came. Context sentences arrive at `onContext`, which is also where the recording status and keypad presses land. The count behind the sentence is on the session as `participantCount`, so there is nothing to parse out of the prose. See [Call handler](/typescript-sdk/call-handler#the-seven-methods). ## Wake phrases and the follow-up window ```ts theme={null} import { GroupGate } from "@komaa/standin-sdk"; const gate = new GroupGate({ wakePhrases: ["assistant", "مساعد"], requireAddress: true, // default followUpWindowMs: DEFAULT_FOLLOW_UP_WINDOW_MS, // 12_000 threadId: session.start.threadId, // default: undefined, read as 1:1 }); ``` The constructor takes one options object, and one gate belongs to one call: the follow-up window is per-call state. `wakePhrases` is the only required field. `decide(transcript, nowMs)` returns a readonly `GateDecision` with two fields: | Field | What it means | | ----------- | ---------------------------------------------------------- | | `respond` | Speak an answer to this turn. | | `addressed` | This turn named the assistant. Opens the follow-up window. | ```ts theme={null} gate.decide("what do you all think?", 1_000); // respond: false, addressed: false gate.decide("assistant, summarise that", 2_000); // respond: true, addressed: true gate.decide("and the second point?", 7_000); // respond: true, addressed: false gate.decide("anyway, lunch", 20_000); // respond: false, addressed: false ``` The window is why `addressed` is reported separately from `respond` rather than folded into it. A turn inside the window is answered **without having been addressed**, and only an addressed turn reopens the window. Those are different facts about the same turn, and conflating them costs you both: you cannot log who actually named the agent, and a follow-up would keep the floor open forever by being answered. The window is stored as a timestamp rather than a latched boolean. A missed wake phrase therefore self-heals by the clock instead of stranding the agent silent, or talkative, for the rest of the meeting. `nowMs` is yours to supply, and the gate never reads a clock itself. It only ever subtracts one of your timestamps from another, so any consistent millisecond source works: the SDK's own LiveKit plugin passes `Date.now()`, and `performance.now()` is the monotonic option if you would rather a system clock adjustment could not open or close the floor. Pick one and use it for every `decide` on that call. It also makes the whole decision testable without waiting twelve seconds. If you also run the [echo guard](/typescript-sdk/realtime-providers), do not reuse a `performance.now()` value there. `shouldSuppressEcho` compares against `Date.now()` internally and needs epoch milliseconds. The gate accepts either because it only compares differences; the echo guard does not. `requireAddress: false` turns the gate off entirely and answers everything, on a meeting thread or not. On a 1:1 call the gate returns `respond: true` for every turn, and `addressed` still tells you truthfully whether the caller used your name. That is worth having even when nothing is being gated. ## A gate with no phrase never opens `gate.active` is the question "is this gate muting anything right now?", and it is false unless all three of these hold: the call is a group, `requireAddress` is on, and **at least one non-blank wake phrase is configured**. ```ts theme={null} const gate = new GroupGate({ wakePhrases: [], threadId: "19:meeting_abc@thread.v2" }); gate.active; // false gate.decide("hello", 1_000); // respond: true ``` An empty wake list means the gate is off, on purpose. A gate with nothing that could ever open it would mute the assistant for the whole call, and nothing in the transcript would ever explain why. Silence is the one failure a caller cannot interpret and an operator cannot debug: it looks identical to a dead socket, a crashed worker and an agent that is thinking. A blank or whitespace-only phrase counts as no phrase, because a configuration file with an empty string in it is the usual way this happens. ## Matching a name `isAddressed(transcript, wakePhrases)` is a case-insensitive match on **word boundaries**, not substrings. ```ts theme={null} import { isAddressed } from "@komaa/standin-sdk"; isAddressed("assistant, what is the plan?", ["assistant"]); // true isAddressed("ASSISTANT, hello", ["assistant"]); // true isAddressed("the assistants are ready", ["assistant"]); // false isAddressed("مرحبا مساعد كيف حالك", ["مساعد"]); // true ``` Boundaries rather than substrings because a substring match on a short name fires inside ordinary words, and the way that failure presents itself is the agent interrupting a meeting, which is the exact thing the gate was added to prevent. The boundary is written as "not a letter, digit or underscore" with Unicode property escapes rather than as a Latin word character, so an Arabic wake phrase behaves the same as a Latin one and needs no special case in your configuration. Phrases are trimmed and lowercased before matching, regex metacharacters in them are escaped, and an empty phrase never matches. Choose a wake phrase that is not an ordinary word in the language the call will be held in. Word boundaries stop `assistant` matching `assistants`; they do not stop a name that happens to be a common noun from matching every time somebody uses it. ## Stop means stop ```ts theme={null} import { isVerbalInterrupt } from "@komaa/standin-sdk"; isVerbalInterrupt("stop by the store"); // false isVerbalInterrupt("ok stop"); // true isVerbalInterrupt("Assistant, stop.", ["assistant"]); // true isVerbalInterrupt("hold on assistant", ["assistant"]); // true isVerbalInterrupt("توقف"); // true ``` `isVerbalInterrupt(text, wakePhrases?)` matches the **whole normalised utterance**, never a substring. "stop by the store" is a sentence somebody said in a meeting, and a substring rule would cut the agent off mid-answer every time it came up. Whole-utterance is the rule that makes this safe to run on every turn. Before the comparison the utterance is normalised: lowercased, apostrophes deleted rather than split on so `that's` becomes `thats`, combining marks and the Arabic tatweel deleted so a vocalised `تَوَقَّف` matches `توقف`, and everything that is not a letter collapsed to a space. Letters in any script survive, which is what lets an Arabic interrupt cut as instantly as an English one. Then filler and wake phrases are peeled off **both ends**, repeatedly until nothing changes, because the name can sit outside the filler or inside it and both orders are things people say. Pass your wake phrases in, or the name you chose will be the thing that defeats the match. The second parameter is typed `string[]`, while `GroupGate.wakePhrases` is `readonly string[]`. Handing the gate's own list straight in is a compile error. Spread it: `isVerbalInterrupt(text, [...gate.wakePhrases])`. What is left has to be **at most four words** and has to match the phrase set exactly. The cap is the belt to the whole-utterance braces: a long sentence cannot reduce to an interrupt by accident, whatever the filler lists do. Interrupt phrases ship for two languages: English and Arabic. `stop`, `stop it`, `stop talking`, `wait`, `hold on`, `hang on`, `never mind`, `pause`, `one second`, `توقف`, `خلاص`, `لحظة` and their close neighbours. The set is a module-private constant in the gate module and there is no hook to extend it, so a phrase your callers use and the set does not is a wrapper around `isVerbalInterrupt`, not a configuration key. Two consequences worth knowing before you rely on it. The wake phrase on its own is an address and not a cut, because `"assistant?"` peels down to nothing and nothing never matches. And phrases that are merely close to the set are not in it: `"could you stop"` returns `false`, because widening the set to catch it is how a whole-utterance rule turns back into substring matching. An interrupt is worth nothing on its own. Pair it with `session.cancelPlayback()`. `onCallerTurn` below is **your own** method and not a `CallHandler` one: wire it to whatever your provider calls a finished caller transcript. Nothing in [Call handler](/typescript-sdk/call-handler#the-seven-methods) delivers a transcript, because only your provider produces one, so a method named for it on a handler is never called by anything. ```ts theme={null} /** The provider reported a finished caller transcript. */ async onCallerTurn(text: string) { if (isVerbalInterrupt(text, [...this.#wakePhrases])) { await this.#call?.cancelPlayback(); // first: un-send what StandIn still holds this.#provider.cancelResponse(); // then: stop the model generating more return; } } ``` `cancelPlayback()` first, because it is the only lever that un-sends audio StandIn already holds. Cancelling the model first stops it producing more, and the caller still hears every buffered sample of the answer they just interrupted. A caller who has said "stop" has stopped listening, and several seconds of the agent carrying on afterwards reads as the agent ignoring them. See [Barge-in](/typescript-sdk/call-handler#cancelplayback-is-the-barge-in). Suppress the reply to the interruption itself too. "stop" does not want an answer, it wants silence, and an agent that says "of course, I will stop" has not stopped. ### A request for silence gives the floor back An interrupt cuts the sentence in progress. It does not close the follow-up window, so in a meeting the assistant would still answer the next thing anybody says. That is right after "wait", because whoever said it is about to ask something. It is wrong after "be quiet". `isDismissal(text, wakePhrases)` tells the two apart, and `gate.closeWindow()` makes the next turn name the assistant again: ```ts theme={null} if (isVerbalInterrupt(text, [...this.#wakePhrases])) { if (isDismissal(text, [...this.#wakePhrases])) { this.#gate?.closeWindow(); // "be quiet": wait to be named again } await this.#call?.cancelPlayback(); this.#provider.cancelResponse(); return; } ``` Every dismissal is an interrupt, so check it inside the interrupt branch. On a 1:1 call there is no window to close and the call does nothing. ## Where to call it On a finished transcript, once, before you spend a model call. ```ts theme={null} /** The provider reported a finished caller transcript. Your method, not the SDK's. */ async onCallerTurn(text: string) { // Interrupts first: "stop" is not a turn to answer, and scoring it for // wake phrases can only end in answering it. if (isVerbalInterrupt(text, [...this.#wakePhrases])) { if (isDismissal(text, [...this.#wakePhrases])) this.#gate?.closeWindow(); await this.#call?.cancelPlayback(); this.#provider.cancelResponse(); return; } const decision = this.#gate!.decide(text, Date.now()); if (!decision.respond) { this.#provider.cancelResponse(); return; } this.#provider.createResponse(); } ``` Three things about that order and that placement. **Interrupts before the gate.** Run the gate first and "stop" is scored as an ordinary turn: inside the follow-up window it is answered, and "of course, I will stop" is the one reply a caller who said stop must never hear. Outside the window the gate refuses it, you return early, and the cut never happens at all while the agent carries on talking. **Finished transcripts, not partials.** A partial has not necessarily reached the name yet, so a gate run on partials refuses turns it would have accepted a syllable later. If you want the window stamped as early as possible, guard the partial path with `isAddressed` and let only that call `decide`: because the window is a timestamp, a stamp can only ever open the floor, never close it. ```ts theme={null} #onCallerTranscript(text: string, final: boolean) { const gate = this.#gate; if (gate === undefined) return; if (!final) { if (isAddressed(text, gate.wakePhrases)) gate.decide(text, Date.now()); return; } this.#lastDecision = gate.decide(text, Date.now()); } ``` **Before the model call, not after.** A refused turn should cost nothing. If your provider answers on its own voice detection, put it in manual response mode while `gate.isGroup` is true and create the response yourself when `decision.respond` is true. A response cancelled after it has started generating is one the caller has already heard the beginning of, and a meeting hears the agent start to speak and then stop, which is more disruptive than either answering or staying quiet. There is one thing to check before any of this: whether your provider gives you a caller transcript at all. Without one there is nothing to gate on and nothing to match an interrupt against. Log that loudly at startup rather than shipping a gate that silently never fires, and leave auto-response on, because an ungated assistant is at least an assistant. ## What is not the same in the Python SDK The gate itself is. `GroupGate`, `GateDecision`, `is_addressed`, `is_meeting_thread`, `is_verbal_interrupt` and `DEFAULT_FOLLOW_UP_WINDOW_MS` exist there under the same names in snake\_case, with the same twelve second default, the same thread-id rule and the same "no phrase means no gate" catch. Porting a gate between the languages changes the casing, and one thing that is language-shaped rather than a decision: the constructor takes a single options object here and keyword arguments there. Two things around it are not the same, and both will bite a port that assumes otherwise. **The interrupt phrase set differs by language and by membership.** This SDK ships English and Arabic; the Python SDK ships English, Arabic, French and German. The lists are not translations of each other either: `"stop talking"`, `"hang on"`, `"pause"` and `"one second"` match here and not there, and `"cancel"`, `"stop stop"` and `"hold up"` match there and not here. There is also no four-word cap in the Python implementation. Test the phrases your callers actually use, in the SDK you are actually running, rather than porting a test suite across. **The echo guard is a different shape entirely.** The Python SDK has an `EchoGuard` class that owns its own playout clock, and because that clock is monotonic, one monotonic source serves both the gate and the guard there. This SDK has a `shouldSuppressEcho` function, no class, and a playout horizon you keep yourself: it must be on the epoch-millisecond scale, so the source that serves both here is `Date.now()`, and the monotonic option the gate would otherwise accept stops the guard firing at all. [Realtime providers](/typescript-sdk/realtime-providers) spells the difference out in full. ## Next The startup buffer and the echo guard, for a speech-to-speech agent. For an agent that is not speech to speech: segmentation, WAV and paced playback. Where the transcript, the context and `cancelPlayback` reach you. The other decision the meeting thread id makes for you. # Meeting recap Source: https://docs.komaa.com/typescript-sdk/minutes Minutes of the call, including what was shown on screen, with a Word document, in the StandIn TypeScript SDK. A recap is the one thing people ask an agent for that it cannot do while the call is happening. It needs the whole conversation, so it happens at the end, and by then the caller has usually gone. What makes this one worth having is the second track. Every transcript-first recap tool is blind to the screen share. Your agent was on the call and could see it. ## Keeping the record Feed the transcript as the call runs, from whichever callbacks your plugin already has. ```ts theme={null} import { Transcript } from "@komaa/standin-sdk"; const transcript = new Transcript(); transcript.add(callerName, "we should push the launch to March"); transcript.add("Assistant", "noted", "assistant"); transcript.addVisual("Sara's shared screen: the Q3 revenue dashboard"); ``` The third argument is the `TurnRole`, `"caller"` unless you say otherwise, and it is what tells the two sides apart in the document. A consecutive turn from the same speaker **and** the same role is merged into the one before it, because a live transcript arrives as fragments and half-sentences fed to a model as separate turns make the minutes read like a stutter. Merging never crosses speakers or roles: filing one person's words under another's name is worse than no attribution, because it is confidently wrong. Both tracks are bounded, because a two-hour meeting sits in the memory of a process that is also carrying live audio. What survives is the tail, since the end of a meeting is what minutes are mostly about. Here are the numbers, so "bounded" is something you can check: | Bound | Value | What it limits | | ---------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MAX_TRANSCRIPT_TURNS` | `600` | Turns held at once. Also the default for `new Transcript({ maxEntries })`. | | `MAX_TRANSCRIPT_VISUALS` | `60` | Things shown. Far fewer than turns, because a screen changes slowly. | | `MAX_TRANSCRIPT_ENTRY_CHARS` | `1000` | How long one entry may grow before the next turn from the same speaker starts a fresh one. Without it, an hour of one person talking coalesces into a single ever-growing entry the entry count can never trim. | | `MAX_TRANSCRIPT_ENTRIES` | `40` | Entries that reach the summarising model. | | `MAX_TRANSCRIPT_CHARS` | `12000` | Characters that reach it. | | `RECAP_MIN_TURNS` | `4` | Below this there is no meeting to summarise, only a greeting. Advisory: it is exported for your plugin to check before it offers a recap at all. `postMinutes` itself only refuses an **empty** transcript, so a two-turn call still gets summarised if you ask. | All six resolve from `@komaa/standin-sdk`. In the Python SDK only `MAX_TRANSCRIPT_ENTRIES`, `MAX_TRANSCRIPT_ENTRY_CHARS` and `RECAP_MIN_TURNS` are on the barrel, and the three hard holding caps are `from standin.minutes import ...` there. Nothing about the values differs, only where you import them from. Consecutive visual repeats are collapsed. The vision lane describes whatever is on screen each time it is asked, and a slide nobody changed would otherwise fill the record with the same line. ## Where the minutes go Decided **once**, before anything is written, and then passed to every later step. `resolveMinutesTarget` returns a `DeliveryTarget`, and it holds three fields and nothing else: a `kind` of `"thread"` or `"caller-dm"`, a `conversationId` and a `tenantId`. ```ts theme={null} import { ChatChannel, PersonalChats, resolveMinutesTarget } from "@komaa/standin-sdk"; // One instance, shared: the chat lane fills it, the call lane reads it. const chats = new PersonalChats(); const chat = new ChatChannel({ respond, chats }); // ... and on a call: const target = resolveMinutesTarget({ threadId: session.start.threadId, humanCount: participants, callerAadId: session.start.caller.aadId, callerChat: chats.forCaller({ callerAadId: session.start.caller.aadId, tenantId: configuredTenant, }), sessionTenantId: session.start.tenantId, configTenantId: configuredTenant, }); ``` `chats` is a [`PersonalChats`](/typescript-sdk/chat#remembering-who-messaged-you), and it is the only honest answer to "where does a 1:1 call post?". A 1:1 call carries no thread, so the caller's own chat has to have been seen on the messages lane first. Both lanes have to be in one process for that, or the memory has to be shared some other way. This is the highest-consequence rule in the feature. A send with no pinned recipient falls back to whatever conversation the sending code last saw, and a customer's meeting minutes are the most sensitive thing this product produces. So the target is one immutable value, and no step downstream is allowed to work out a recipient of its own. Three details are load-bearing, and each one is a real failure that happened: **A meeting thread counts even when the participant count says one.** The count only arrives on topologies that send a participants frame, so on some deployments it stays pinned at 1. A count-only test delivered the minutes of a group call into one attendee's private chat. **The caller's own chat is admitted by scope, never by the shape of its id.** A personal chat and a group thread are told apart by what the message said its scope was. Testing the id prefix inverts it, admitting a team channel and rejecting every real private chat. **The tenant is the session's, then the configured one, then the remembered sender's.** Never the caller's own. A guest's tenant describes the organisation they came from, which is not the one this worker is bound to, and it is the one plausible-looking source that is actively wrong. A call that identifies nobody gets no target, and therefore no minutes. That is the correct outcome: there is no conversation that can be asserted as theirs. Pass a list when more than one place is admissible, best first. The walk advances **only on a 404**, because that is the only answer that proves nothing was delivered, so it is the only one where trying again cannot duplicate. A 401 or a 5xx would fail identically elsewhere. The SDK's own chat lane writes to a socket and gets no status back, so it can report that a message was sent and never that it was refused. A poster built on it never advances the walk, which is the safe behaviour rather than a bug. Supply a poster that surfaces a status if you want the fallback to fire. ## Writing it up ```ts theme={null} import { postMinutes } from "@komaa/standin-sdk"; const result = await postMinutes( (prompt) => consultant.ask(prompt), transcript, target, postToChat, { documentDir: "/var/standin/minutes", subtitle: `Call with ${caller} - ~${durationMin} min, ${humans} human participants.`, }, ); await lane.say(result.spoken); ``` Wrap the summariser in an arrow function rather than passing `consultant.ask` bare. It type-checks either way, because `Summariser` is just `(prompt: string) => Promise`, and then throws a `TypeError` at the first private field read: `ask` reads the `Consultant`'s own state, and detached from its instance there is none. The same applies to any method you hand in as a callable here, and to your `Poster`. `lane.say` above is the `VoiceLane` this call is running on. `CallSession` has no `say`: the session sends PCM, and turning a sentence into PCM is either the lane's job or your provider's. A realtime plugin has neither, and hands `result.spoken` back as the tool result instead, which is what the model then reads out. `documentDir` decides whether a Word document is written at all: omit it and none is. `subtitle` is the line under the document title. `assistantLabel` and `callerLabel` name the two sides in the attributed transcript. `postMinutes` never raises. It normally runs during teardown, where an exception takes the whole teardown with it, so every failure comes back as a sentence instead. It degrades a step at a time: a document that cannot be built sends text only, a send that fails is logged, and the spoken sentence is always there. Two outcomes look similar and are kept apart on purpose. A call with nothing said is told it was too short. A call with plenty said but no conversation to post into is told exactly that. Conflating them tells people their conversation did not count when it did. | Field | What it holds | | ----------- | ----------------------------------------------------------------------------------------- | | `spoken` | One sentence for the agent to say. Always present, including on failure. | | `minutes` | The minutes themselves, so a caller can do something else with them when the post failed. | | `document` | Where the Word document was written, when one was. | | `delivered` | Whether the minutes actually reached the chat. | | `target` | Which of the admissible conversations took them. | Your poster may answer with a plain `true` or with a structured outcome carrying a status. Both are read correctly. Do not hand back an object and expect it to be truth-tested: every object is truthy, and a post StandIn rejected would report as delivered. ## The document `writeMinutesDocx` emits a Word-openable `.docx` with no dependencies. A document format library would be a dependency every install pays for so that the small fraction who ask for minutes get a file, which is the wrong trade for an SDK. Give it `sections` from `parseMinutesSections` and it renders real headings instead of a flat wall of lines. A model asked for `### Key points` will write `## Key points` or `**Key points:**` depending on the model and the day, so all of those are accepted; a section that ends up with nothing in it is left out entirely rather than printed as a heading over white space. Give it `transcript` and it appends an attributed transcript, which is the half a transcript-only tool cannot produce: the call gave you the real speaker per utterance. A turn that already carries its own `Name:` prefix is written through untouched, because relabelling it as a generic caller destroys the attribution and double-prefixing reads as a transcription fault. Delivery to the chat is text. The document is written to disk beside it, for whoever keeps the record. A meeting chat cannot be sent a file by a bot the way a person can, so a document promised into the chat would be a promise that quietly fails. Say so in the message rather than leaving it as a silent difference: `DOCUMENT_NOT_ATTACHED` is that sentence. ## The prompt `minutesPrompt` asks for Key Points, Decisions, Action Items and, when anything was shown, Presented. The instruction not to infer what was on screen is the load-bearing one. A model handed "Sara shared a dashboard" will happily invent the numbers on it, and minutes that invent numbers are worse than minutes with a gap. `isSummaryRequest` recognises somebody asking for the write-up in conversation. It needs both halves: "summarise" alone is asked about a document, an email, or a page the agent is looking at. `MINUTES_TOOL` is the tool declaration, ready to register on `CallTools`. It is named `post_meeting_minutes`, not after the function that does the work, because a model handed two things with one name cannot tell which it is calling. It is not built in: whether there is anywhere to post depends on the call, and `resolveMinutesTarget` is what answers that. Answer the model the moment it calls, then run the recap. A tool call with no result stalls the turn, and the recap is a full model run plus a document write plus a send, so the caller sits in silence wondering whether anything is happening. ## From the OpenClaw and Hermes plugins You do not have to wire `postMinutes` yourself. Each plugin has a switch (`meetingRecap` / `meeting_recap`), off by default. When it is on, hang-up writes a **restart-recoverable local spool** first (`STANDIN_RECAP_DIR`, else `STANDIN_STATE_DIR/recap`, else `~/.standin/state/recap`) and posts after the call is already gone. A process restart drains unfinished jobs if that directory survives the process. Treat recap as best-effort unless that directory is on storage that survives a process or container restart: the default path is local process disk, which is ephemeral in most containers. The file stays until the minutes post, the job expires (24 hours), or it hits the retry limit (8). OpenClaw always opens its own **listen-only** chat lane for recap and never answers on it. Hermes reuses the host `respond` handler when one exists, and opens a listen-only lane only when it does not. The host must provide an outbound chat lane and a summarization consult. Hang-up does not await the consult. Meeting recaps are best-effort. They may be lost if the worker exits during processing. Delivery needs StandIn's outbound chat socket, which is the [StandIn AI Teammate app](/tiers#the-standin-ai-teammate-app). A Free / bring-your-own-bot identity answers chat on its own Azure Bot messaging URL, not this socket. Plugin pages: [OpenClaw](/typescript-sdk/plugins/openclaw#meeting-recap). The Python twin is [Hermes Agent](/python-sdk/plugins/hermes-agent#meeting-recap). ## Next `PersonalChats`, and the reply guard every post rests on. The slow agent that writes the minutes while the fast one talks. Registering `MINUTES_TOOL` alongside the built-ins. Where the visual track of the transcript comes from. # TypeScript SDK Source: https://docs.komaa.com/typescript-sdk/overview Put your AI agent into a Microsoft Teams call from Node.js. The shared core every StandIn TypeScript plugin builds on. `@komaa/standin-sdk` is one end of a socket. StandIn handles the Microsoft Teams side and dials your worker, once per call, over an authenticated WebSocket. This package answers that dial, speaks the call protocol, and hands each call to your code. ```bash theme={null} npm install @komaa/standin-sdk ``` One package. The core is the bare specifier and imports nothing from the plugins, so `import { CallServer } from "@komaa/standin-sdk"` works with no framework installed. Each plugin is a subpath of that same package: `@komaa/standin-sdk/openclaw`, `@komaa/standin-sdk/echo`. ## What you actually write The whole contract between the SDK and your agent is seven optional methods and a session object. Nothing extends anything, and you implement only the ones you care about. ```ts theme={null} import { CallServer, type CallSession } from "@komaa/standin-sdk"; class EchoHandler { #call: CallSession | undefined; async onStart(session: CallSession) { this.#call = session; } async onCallerAudio(pcm: Buffer) { await this.#call?.sendAudio(pcm); } } const server = new CallServer({ handlerFactory: () => new EchoHandler() }); await server.start(); ``` That is a worker that answers a real Microsoft Teams call. Everything else on this page is detail about what the server is doing behind those two methods. ## Who owns what `CallServer` owns everything that is the same whichever agent framework is on the other side. That list is not marketing, it is the reason the SDK exists: * the socket StandIn dials, and the HMAC handshake with its single-use replay guard * capacity, draining, and the one-live-session-per-call rule * the wire protocol and the frame loop * outbound sequence numbers and the audio timeline, for voice and for the video tile alike * [five timers](/typescript-sdk/call-server#the-watchdogs): a pre-start watchdog, a caller-audio idle watchdog, an `onStart` timeout, a stale-call reaper and an optional call-duration ceiling * idempotent teardown that always frees the slot Your handler owns the part that differs: what to do with a caller's voice, and where the reply comes from. If you find yourself reimplementing a sequence number or a resampler, that is a bug in the SDK, not in your plugin. ## The two SDKs are one API The wire protocol, the audio format and the HMAC are byte-identical between the languages, and shared conformance vectors prove it rather than promising it: both protocol modules are generated from one schema and gated against drift. The core seam is the same in both. Same handler methods, same session, same watchdogs, same defaults. A handler ported between the languages changes only its method names. ```python Python theme={null} class MyHandler: async def on_start(self, session: CallSession) -> None: self._call = session async def on_caller_audio(self, pcm: bytes) -> None: await self._call.send_audio(pcm) ``` ```ts TypeScript theme={null} class MyHandler { #call!: CallSession; async onStart(session: CallSession) { this.#call = session; } async onCallerAudio(pcm: Buffer) { await this.#call.sendAudio(pcm); } } ``` Python is snake\_case, TypeScript is camelCase. Around the seam a few peripheral helpers exist in one language only, and the page each one belongs to says so where it applies. Three that matter here: * **Timers are milliseconds in TypeScript and seconds in Python.** `preStartTimeoutMs: 10000` against `pre_start_timeout=10.0`. The names differ too, so the compiler catches a straight port, but a config file copied between two workers does not. * **The two optional video callbacks sit differently.** Every `CallHandler` member is optional in TypeScript, so `onVideoFrame` and `onSpeakerChange` are simply two more of them. Python keeps them on separate `VideoHandler` and `SpeakerHandler` protocols, because `CallHandler` there is `runtime_checkable` and a sixth member would break `isinstance` for every handler written before the vision lane existed. * **Who may call the agent is a TypeScript module.** `isInboundCallAllowed` and its three companions ship in this SDK only. A Python worker writes the same check in its own handler. See [Security](/typescript-sdk/security#who-may-call-the-agent). ## Why the plugin lists differ per language Most plugins exist in both languages, and the ones that do not are settled by the same rule: **which language a plugin lives in is decided by the framework it integrates, not by preference.** ElevenLabs, Deepgram, Cartesia and LiveKit are in both trees. [OpenClaw](/typescript-sdk/plugins/openclaw) has to be TypeScript because it loads inside the OpenClaw gateway process and consumes in-process objects there. Hermes Agent is Python for the same reason, in reverse. [OpenAI Realtime](/typescript-sdk/plugins/openai) is a TypeScript plugin. Nothing is missing from either side of the wire: both SDKs speak the same protocol, run the same conformance vectors, and answer the same calls. ## What is in the package | Source | Import specifier | How it is used | | ------------------------ | ------------------------------- | ----------------------------------------------------------------------------------- | | `src/` | `@komaa/standin-sdk` | the core: `CallServer`, the session types, audio helpers, the chat and vision lanes | | `src/plugins/elevenlabs` | `@komaa/standin-sdk/elevenlabs` | an ElevenLabs agent takes the call, plus `standin-elevenlabs` | | `src/plugins/deepgram` | `@komaa/standin-sdk/deepgram` | a Deepgram Voice Agent takes the call, plus `standin-deepgram` | | `src/plugins/cartesia` | `@komaa/standin-sdk/cartesia` | a Cartesia Line agent takes the call, plus `standin-cartesia` | | `src/plugins/openai` | `@komaa/standin-sdk/openai` | an OpenAI Realtime model takes the call, plus `standin-openai` | | `src/plugins/livekit` | `@komaa/standin-sdk/livekit` | a LiveKit agent takes the call, plus `standin-livekit` | | `src/plugins/openclaw` | `@komaa/standin-sdk/openclaw` | loaded by the OpenClaw gateway, not imported by you | | `src/plugins/echo` | `@komaa/standin-sdk/echo` | the echo handler, plus the `standin-echo` command | Directory names are short because the repository is already called `standin`. The published name carries the scope because a registry is global. They are allowed to differ. ## Requirements * Node.js 20 or newer for the core. The OpenClaw plugin follows its host and wants 22.19 or newer, a requirement that travels with the `openclaw` peer dependency rather than with the core. * A StandIn identity and its connection secret, from [standin.komaa.com](https://standin.komaa.com) * A public `wss://` route to your worker, so StandIn can dial in ## The names you reach for first Every name below resolves from the bare specifier. This is the handful a first worker needs, not the whole surface: ```ts theme={null} import { CallServer, type CallHandler, type CallSession, type HandlerFactory, FrameAligner, resamplePcm16, SAMPLE_RATE_HZ, REALTIME_SAMPLE_RATE_HZ, FRAME_BYTES, ChatChannel, parseInbound, buildReply, signHandshake, verifyHandshake, setLogger, StandInError, VERSION, } from "@komaa/standin-sdk"; ``` ## The whole surface, by lane The barrel re-exports thirty-one modules. Nothing here is a subpath import: a plugin subpath is only ever a framework adapter, and every name below comes from `@komaa/standin-sdk` itself. | Lane | What is on the barrel | Page | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | | The call seam | `CallServer`, `CallServerOptions`, `CallHandler`, `CallSession`, `HandlerFactory`, `StandInError`, `setLogger`, `VERSION` | [Call server](/typescript-sdk/call-server), [Call handler](/typescript-sdk/call-handler) | | Audio | `FrameAligner`, `resamplePcm16`, `frameDurationMs`, `pcm16Rms`, `FRAME_BYTES`, `FRAME_MS`, `SAMPLE_RATE_HZ`, `REALTIME_SAMPLE_RATE_HZ`, `BYTES_PER_SAMPLE`, `NUM_CHANNELS` | [Audio](/typescript-sdk/audio) | | Turn-taking | `VoiceLane`, `UtteranceSegmenter`, `PacedPlayback`, `decodeWav`, `encodeWav`, the `TROUBLE_*` sentences | [Turn-taking](/typescript-sdk/voice) | | Realtime providers | `StartupBuffer`, `MAX_PENDING_AUDIO`, `MAX_PENDING_CONTEXT`, `shouldSuppressEcho`, `ECHO_SUPPRESSION_WINDOW_MS`, `ECHO_BARGE_IN_RMS` | [Realtime providers](/typescript-sdk/realtime-providers) | | Group calls | `GroupGate`, `isAddressed`, `isVerbalInterrupt`, `isMeetingThread` | [Group calls](/typescript-sdk/group-calls) | | Vision | `FrameDescriber`, `parseVideoFrame`, `displayImage`, `displayFrame`, `VisionTools`, `KeyframeStore`, `VisionBudget`, `AmbientVision` | [Vision and the avatar](/typescript-sdk/vision) | | The avatar | `expression`, `inferEmotion`, `speechMarks`, `ExpressionCue`, `EMOTIONS`, `TurnLipSync`, `estimateVisemes`, `visemesFromAlignment`, `TileStream`, `jpegEncoder` | [The avatar](/typescript-sdk/avatar) | | Call tools | `CallTools`, `toolSchemas`, `BUILT_IN_TOOLS`, `SHOW_PAGE_TOOL` | [Call tools](/typescript-sdk/call-tools) | | Consulting | `Consultant`, `BackgroundTasks`, `CONSULT_TOOL`, `BACKGROUND_TASK_TOOL` | [Consulting](/typescript-sdk/consulting) | | Meeting recap | `Transcript`, `postMinutes`, `resolveMinutesTarget`, `minutesPrompt`, `writeMinutesDocx`, `MINUTES_TOOL` | [Meeting recap](/typescript-sdk/minutes) | | Chat | `ChatChannel`, `parseInbound`, `buildReply`, `isPersonal`, `PersonalChats` | [Chat](/typescript-sdk/chat) | | Attachments | `buildChatTurn`, `fetchChatImages`, `fetchChatAudio`, `transcribeVoiceMessages`, `spoolClip`, `chatImageDataUrl` | [Attachments in chat](/typescript-sdk/chat-attachments) | | Sending a picture | `outboundImage`, `sniffImageType`, `sanitizeImageName`, `parseMedia`, `loadMedia`, `mediaRoots` | [Sending a picture](/typescript-sdk/sending-pictures) | | Reaching people | `OutboundCaller`, `OutboundPolicy`, `OutboundLane`, `PendingMessages`, `VoiceDelivery`, `LiveCalls` | [Reaching people](/typescript-sdk/reaching-people) | | Security | `signHandshake`, `verifyHandshake`, `signBody`, `verifyBody`, `signRequest`, `canonicalRequest`, `isInboundCallAllowed`, `normalizePhoneNumber`, `assertPublicHttpUrl`, `fetchPublicImage` | [Security](/typescript-sdk/security) | | The wire itself | `SessionStart`, `Caller`, `parseMessage`, `parseSessionStart`, `decodePcm`, `contextSentences`, `audioFrame`, `assistantCancel`, `sessionEnd`, `pong` | [Security](/typescript-sdk/security#the-generated-wire-builders) | | Configuration | `optional`, `required`, `flag`, `jsonObject`, `vendorHost` | [Configuration](/typescript-sdk/configuration) | | Checking the install | `runSmoke`, `smokeReport`, `SyntheticCall` | [Checking the install](/typescript-sdk/checking-the-install) | ## Where the SDK stops Three boundaries, and each is a real line rather than a disclaimer. **Speech and reasoning are your framework's.** Speech recognition, speech generation and the model that decides what to say belong to your provider. The SDK carries PCM in both directions and gives you the turn-taking pieces if you are assembling three vendors yourself, but nothing in this package transcribes, synthesizes or thinks. **The Microsoft side is StandIn's.** StandIn handles it. Your worker never holds a Bot Framework credential and never talks to Microsoft. **StandIn draws the avatar tile; the SDK sends it hints.** This is the boundary that is most often described wrongly, so it is worth stating exactly. The SDK does carry the vision and avatar lane: `session.express` names an emotion, `session.sendSpeechMarks` sends the viseme timeline that drives lip-sync, `session.displayImage` puts a picture on the tile, `session.sendTileFrame` puts one frame of your own video on it, and `session.latestVideoFrame` and `CallHandler.onVideoFrame` are how the caller's camera and screen share reach you. Whole modules back those: `avatar`, `lipsync`, `vision`, `visionTools`, `tile` and `ambient`, all on the barrel above. What the SDK does not do is render anything. It never composites a face, never encodes the avatar and never knows how the tile is drawn. See [Vision and the avatar](/typescript-sdk/vision) and [The avatar](/typescript-sdk/avatar). A frame type this SDK does not recognise is ignored by contract, which is what lets an older worker and a newer StandIn interoperate. That rule is about unknown frames, not about the avatar: those are frames the SDK itself sends and parses. ## Next From one install to a Microsoft Teams call that answers. The seven methods and the session object. Rates, frames, and the clipping trap. The two HMAC lanes and their replay windows. # Cartesia plugin Source: https://docs.komaa.com/typescript-sdk/plugins/cartesia Answer Microsoft Teams calls with a Cartesia Line agent, using the StandIn TypeScript SDK. `@komaa/standin-sdk/cartesia` puts [Cartesia](https://docs.cartesia.ai/line) on a real Microsoft Teams call. StandIn answers the call and dials your worker; this plugin answers that dial, opens one session per call, and relays the audio both ways. ## Install and run There is nothing to install beyond the SDK. Cartesia is reached over an ordinary WebSocket, so this plugin adds no dependency. ```bash theme={null} npm install @komaa/standin-sdk ``` ```bash theme={null} export STANDIN_SECRET=... # your StandIn connection secret export CARTESIA_API_KEY=... export CARTESIA_AGENT_ID=... npx standin-cartesia ``` Expose port `9442` at the `/msteams/calling` path and register the public `wss://` URL as your StandIn identity's agent voice URL. The full walkthrough is in the [Quickstart](/typescript-sdk/quickstart), and there is a runnable example at [examples/cartesia-msteams-connector](https://github.com/komaa-com/standin/tree/main/examples/cartesia-msteams-connector). ## The audio format Audio is pinned to `pcm_16000` in both directions, which is exactly what a Microsoft Teams call carries. ## Your agent stays your agent Cartesia runs the agent, so this plugin is transport and nothing else. There are no call tools to declare here, because whatever the agent does it does in its own code on Cartesia's platform. Caller details reach that code as stream metadata, call context arrives as `custom` events, and a key press arrives as a real `dtmf` event. `CARTESIA_SYSTEM_PROMPT` is optional on purpose. Left unset, the agent keeps exactly the prompt you wrote on Cartesia's side. Set it and the caller's details are appended to yours. Nothing here ever silently rewrites a deployed prompt. ## Inside your own worker The plugin is a `CallHandler` like any other: ```ts theme={null} import { CallServer } from "@komaa/standin-sdk"; import { CartesiaHandler, cartesiaConfigFromEnv } from "@komaa/standin-sdk/cartesia"; const config = cartesiaConfigFromEnv(); const server = new CallServer({ handlerFactory: () => new CartesiaHandler({ config }), }); await server.start(); ``` Read the configuration **once**, then close over it. `handlerFactory` runs once per call, so `new CartesiaHandler()` with nothing passed reads the environment again on every call, and a key removed after startup would fail the next caller rather than failing you. `serve()`, which is what `npx standin-cartesia` runs, does exactly this, then waits for `SIGINT` or `SIGTERM` before calling `server.aclose()`. `server.start()` returns as soon as the listener is bound, so a script that ends there exits before a single call arrives, and `aclose()` is what drains live calls and releases the port. ## Configuration | Variable | What it is | | ------------------- | ---------------------------------------------------------------------------------------------------------------- | | `CARTESIA_API_KEY` | Your API key. Used only to mint a per-call token over HTTPS, so the long-lived key never rides the agent socket. | | `CARTESIA_AGENT_ID` | Which Line agent answers the call. | | Variable | What it is | | ------------------------ | ------------------------------------------------------------------------------------------------------- | | `CARTESIA_VOICE_ID` | Override the agent's configured voice. | | `CARTESIA_INTRODUCTION` | What the agent says first. | | `CARTESIA_SYSTEM_PROMPT` | Appended to, never replacing, the prompt on Cartesia's side. Leave it unset and nothing is added. | | `CARTESIA_VERSION` | API version header. Defaults to `2025-04-16`. Pin it when Cartesia ships a version you have not tested. | | `CARTESIA_API_HOST` | Defaults to `api.cartesia.ai`. Must be a cartesia.ai host. | The configuration is read once when the worker starts, not per call, so a missing key stops the worker at startup rather than surprising the first caller. `CARTESIA_API_HOST` is checked against the cartesia.ai suffix, because your API key travels to it: a wrong host would be credential leakage rather than a failed call. The reasoning behind that shape is on [Configuration](/typescript-sdk/configuration). ## Next The startup buffer, the echo guard and barge-in that every provider plugin sits on. Every `STANDIN_` variable, and the helpers for reading your own. # Deepgram plugin Source: https://docs.komaa.com/typescript-sdk/plugins/deepgram Answer Microsoft Teams calls with a Deepgram Voice Agent, using the StandIn TypeScript SDK. `@komaa/standin-sdk/deepgram` puts [Deepgram](https://developers.deepgram.com/docs/voice-agent) on a real Microsoft Teams call. StandIn answers the call and dials your worker; this plugin answers that dial, opens one session per call, and relays the audio both ways. ## Install and run There is nothing to install beyond the SDK. Deepgram is reached over an ordinary WebSocket, so this plugin adds no dependency. ```bash theme={null} npm install @komaa/standin-sdk ``` ```bash theme={null} export STANDIN_SECRET=... # your StandIn connection secret export DEEPGRAM_API_KEY=... npx standin-deepgram ``` Expose port `9442` at the `/msteams/calling` path and register the public `wss://` URL as your StandIn identity's agent voice URL. The full walkthrough is in the [Quickstart](/typescript-sdk/quickstart), and there is a runnable example at [examples/deepgram-msteams-connector](https://github.com/komaa-com/standin/tree/main/examples/deepgram-msteams-connector). ## The audio format Audio is pinned to `linear16` at 16 kHz in both directions, which is exactly what a Microsoft Teams call carries, so the hot path is a copy. ## What the agent can do about the call Declared automatically when the call starts, so there is nothing to set up on the provider's side. | Tool | Parameters | What it does | | ------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `end_call` | none | Hang up. | | `express` | `emotion` | Set the avatar's expression on the bot's tile. See [The avatar](/typescript-sdk/avatar#the-emotion-the-face-wears). | | `show_image` | `url`, `caption?`, `display?` | Put a picture on the bot's video tile. | | `look` | `source?`, `question?` | Look at the caller's screen share or camera. | | `look_back` | `question?` | Look again at something the caller has already moved past. Recorded calls only. | `show_image` fetches the URL through the SDK's guard: public hosts only, no private or link-local addresses, and the address is re-checked at connect time. `display` is `fullscreen` or `overlay`, and this plugin routes the call through `CallTools.dispatch`, so the model's choice is honoured. See [Vision and the avatar](/typescript-sdk/vision) for what each placement is for, and [Sending a picture](/typescript-sdk/sending-pictures) for putting a picture into chat instead. A Voice Agent hears but does not see, so `look` needs a vision model of your choosing. Set `STANDIN_VISION_API_URL` and `STANDIN_VISION_MODEL` to any OpenAI-compatible endpoint that accepts images, including one you run yourself. The frame is sent for inference and **not stored**, and only the description comes back. Without it the agent is told plainly that looking is unavailable, rather than being left with silence. The whole surface is on [Vision and the avatar](/typescript-sdk/vision). Pass a `describer` in the handler options to point this plugin at a different vision model from the one the environment names, which is how one worker answers two identities with two vision budgets. Build it with `FrameDescriber.fromEnv()` or its constructor, both on [Vision and the avatar](/typescript-sdk/vision#giving-a-voice-model-eyes). ## How context reaches the agent The Voice Agent API has no non-interrupting context message, so this plugin folds participant counts, key presses and recording changes into the agent's prompt and pushes it with an update. That has a cost worth knowing: **the prompt is resent in full on every change**, so the context section is a rolling window of the last eight notes rather than a transcript. A meeting where people join and leave repeatedly keeps the recent notes and drops the old ones, which is the right trade when the alternative is a prompt that grows for the length of the call. Context that arrives before the agent socket is open is held rather than dropped, then folded in once it exists. The "there are N people here, stay quiet" line and the recording change both land in exactly that gap. ## Tools of your own A custom tool runs in **your** worker, so it can reach whatever your worker can reach: ```ts theme={null} import { CallServer } from "@komaa/standin-sdk"; import { DeepgramHandler, deepgramConfigFromEnv, type CustomTool, } from "@komaa/standin-sdk/deepgram"; const tools: CustomTool[] = [ { name: "open_ticket", description: "Open a support ticket for the caller.", parameters: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"], }, // ctx.call is the live call; ctx.recording says whether it is being recorded. handler: async (params, ctx) => `opened ticket for ${String(params.summary)}`, }, ]; const config = deepgramConfigFromEnv(); const server = new CallServer({ handlerFactory: () => new DeepgramHandler({ config, tools }), }); await server.start(); ``` The `description` is what the model reads to decide whether to call it, so write it for a model rather than for a developer. Keep the handler fast: the caller is waiting in silence while it runs. A custom tool may not shadow a built-in one. That is refused when the handler is built rather than at the first call, because a shadowed `end_call` is an agent that has quietly lost the ability to hang up. ## Inside your own worker The plugin is a `CallHandler` like any other: ```ts theme={null} import { CallServer } from "@komaa/standin-sdk"; import { DeepgramHandler, deepgramConfigFromEnv } from "@komaa/standin-sdk/deepgram"; const config = deepgramConfigFromEnv(); const server = new CallServer({ handlerFactory: () => new DeepgramHandler({ config }), }); await server.start(); ``` Read the configuration **once**, then close over it. `handlerFactory` runs once per call, so `new DeepgramHandler()` with nothing passed reads the environment again on every call, and a key removed after startup would fail the next caller rather than failing you. `serve()`, which is what `npx standin-deepgram` runs, does exactly this, then waits for `SIGINT` or `SIGTERM` before calling `server.aclose()`. `server.start()` returns as soon as the listener is bound, so a script that ends there exits before a single call arrives, and `aclose()` is what drains live calls and releases the port. ## Configuration | Variable | What it is | | ------------------ | -------------------------------------------------- | | `DEEPGRAM_API_KEY` | Your API key. Never logged, never sent to StandIn. | | Variable | What it is | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `DEEPGRAM_LISTEN_MODEL` | Speech to text. Defaults to `nova-3`. | | `DEEPGRAM_SPEAK_MODEL` | Text to speech. Defaults to `aura-2-thalia-en`. | | `DEEPGRAM_THINK_PROVIDER` | Which vendor reasons. Defaults to `open_ai`. | | `DEEPGRAM_THINK_MODEL` | Defaults to `gpt-4o-mini`. | | `DEEPGRAM_THINK_ENDPOINT_URL` | Reason with your own model instead. | | `DEEPGRAM_THINK_ENDPOINT_HEADERS` | A JSON object of headers for that endpoint. Carries your model credentials, so it is never logged. Anything but a JSON object is refused when the configuration is read. | | `DEEPGRAM_INSTRUCTIONS` | The agent's base prompt. | | `DEEPGRAM_GREETING` | What the agent says first. | | `DEEPGRAM_LANGUAGE` | Defaults to `en`. | | `DEEPGRAM_AGENT_HOST` | The Voice Agent socket host. Defaults to `agent.deepgram.com`. Must be a deepgram.com host. | | `DEEPGRAM_API_HOST` | The host the REST calls go to. Defaults to `api.deepgram.com`. Must be a deepgram.com host. | | `DEEPGRAM_LOG_TRANSCRIPTS` | Exactly `true` to log turns. Gated a second time on the call being recorded. | The think-endpoint pair is the reason most people pick this plugin: point `DEEPGRAM_THINK_ENDPOINT_URL` at a model of your own and Deepgram keeps the listening and the speaking while your model does the reasoning. The configuration is read once when the worker starts, not per call, so a missing key stops the worker at startup rather than surprising the first caller. Both hosts are checked against the deepgram.com suffix, because your API key travels to them: a wrong host would be credential leakage rather than a failed call. The reasoning behind that shape is on [Configuration](/typescript-sdk/configuration). ## Next The startup buffer, the echo guard and barge-in this plugin sits on. What `look`, `show_image` and `express` reach. # Echo plugin Source: https://docs.komaa.com/typescript-sdk/plugins/echo The smallest StandIn plugin that answers a real Microsoft Teams call. Copy it to start your own. `@komaa/standin-sdk/echo` is the smallest thing that answers a real Microsoft Teams call. It sends the caller's voice back, and it exists for two reasons: to be the thing you run **before** you suspect your own agent, and to be the thing you copy when you start your own. If the echo answers, your secret, your tunnel and your StandIn identity are all correct, and whatever breaks next is your agent rather than the transport. ## Run it Node.js 20 or newer: ```bash theme={null} npm install @komaa/standin-sdk STANDIN_SECRET="your-StandIn-connection-secret" npx standin-echo ``` Then expose port 9442 over HTTPS, register the public `wss://` URL with its `/msteams/calling` path as your StandIn identity's agent voice URL, call the identity from Microsoft Teams, and talk. You should hear yourself. The [quickstart](/typescript-sdk/quickstart) has the portal steps and [Expose your agent](/expose) has the mount command and the probes. ## The whole plugin ```ts theme={null} import { CallServer, type CallSession } from "@komaa/standin-sdk"; export class EchoHandler { #call: CallSession | undefined; async onStart(session: CallSession): Promise { this.#call = session; console.info( `call ${session.callId} from ${session.start.caller.displayName ?? "unknown"}`, ); } async onCallerAudio(pcm: Buffer): Promise { // Your agent goes here. PCM16, 16 kHz, mono, little-endian, the same format // sendAudio expects back. await this.#call?.sendAudio(pcm); } async onGoodbye(text: string): Promise { // StandIn is ending the call and wants this line spoken first. A real plugin // would interrupt the agent and say it. console.info(`goodbye: ${text}`); } } export async function serve(): Promise { const server = new CallServer({ handlerFactory: () => new EchoHandler() }); await server.start(); await new Promise((resolve) => { const stop = (): void => resolve(); process.once("SIGINT", stop); process.once("SIGTERM", stop); }); await server.aclose(); } ``` That is the whole plugin, minus the CLI wrapper behind `standin-echo`, the licence header and the doc comments. One line differs from the file in the repository on purpose: that copy reaches the core by relative path, because it lives inside the package, and yours reaches it by package name. Note what is **not** there: no sequence number, no timestamp, no handshake, no capacity check, no watchdog. All of that is [`CallServer`](/typescript-sdk/call-server). Note also that this handler defines three of the seven methods on `CallHandler`. `onVideoFrame`, `onSpeakerChange`, `onContext` and `aclose` are absent because it does not need them, and the server treats a missing method as a no-op: the interface is all-optional, so nothing extends anything. See [Call handler](/typescript-sdk/call-handler#the-seven-methods). ## Make it yours Replace `onCallerAudio` with your framework's agent loop. Everything else stays. ```ts theme={null} async onCallerAudio(pcm: Buffer): Promise { const reply = await myFramework.respond(pcm); await this.#call?.sendAudio(reply); } ``` If your framework speaks at 24 kHz, read [Audio](/typescript-sdk/audio) before you write a resampler. The SDK ships one, along with the frame aligner that stops every turn losing its last few milliseconds. ## Write your own handler You do not need the SDK's source, a fork or a registration to write a handler. Copy the class out of the block above into your own module, change `onCallerAudio`, and pass it as `handlerFactory`: ```ts theme={null} import { CallServer } from "@komaa/standin-sdk"; const server = new CallServer({ handlerFactory: () => new MyHandler() }); await server.start(); ``` There is nothing to declare anywhere. `CallHandler` is an interface of optional methods, so your class is a handler because it has the right method names, not because it extends or registers. That is the whole seam, and it is the same one every plugin on this site uses. ## What to add first Once the echo answers, three things turn the template into something usable: **Barge-in.** `await session.cancelPlayback()` the moment your provider reports the caller started speaking. Without it the bot keeps talking over the caller for the length of the buffered audio. See [`cancelPlayback()` is the barge-in](/typescript-sdk/call-handler#cancelplayback-is-the-barge-in). **Frame alignment.** If your provider speaks 24 kHz, resample and align before sending. See [Audio](/typescript-sdk/audio). **Cleanup.** Add `aclose(reason)` to close your provider socket. It is always called exactly once, on every path, before the slot is freed. ## Contribute a plugin to the SDK This part is different: it is for adding a plugin to the `@komaa/standin-sdk` package itself, so it assumes a clone of the repository rather than an `npm install`. If you are building for your own deployment, the two sections above are the ones you want. Every plugin lives inside the one package, so adding one publishes nothing new. ```bash theme={null} cp -r libraries/typescript/src/plugins/echo libraries/typescript/src/plugins/ ``` One entry under `exports` in `libraries/typescript/package.json`, pointing `@komaa/standin-sdk/` at the emitted `dist/plugins//index.js`. The directory name stays short; the published specifier carries the scope, because a registry is global. The copied directory brings a `cli.ts` with it, so add the matching `bin` entry in the same file, `standin-` pointing at `dist/plugins//cli.js`, or `npx standin-` resolves to nothing and the install line on your page is wrong from the first day. A framework the plugin needs is a **peer** dependency, marked optional in `peerDependenciesMeta`. The core imports nothing from `plugins/`, so somebody installing the SDK for one plugin never drags in another's framework. ```bash theme={null} make ts-check ``` `make py-check` is its Python counterpart, and `make check` runs both halves plus the protocol and documentation checks. Then open a pull request against [`komaa-com/standin`](https://github.com/komaa-com/standin). Write a plugin when a framework requires its own plugin registry, private internals, or worker lifecycle hooks. If the difference is only configuration, prefer a preset or a documentation page. A plugin needs a named maintainer who will retest it for upstream minor versions. ## Parity The Python twin of this file is `standin.plugins.echo`, the same handler with the same method names in that language's casing. Core SDK features land in both languages in the same change, with matching defaults and behaviour. The full guide is [CONTRIBUTING.md](https://github.com/komaa-com/standin/blob/main/CONTRIBUTING.md). ## Next All seven methods, with a full worked handler. Every option you may want to change once calls are real. # ElevenLabs plugin Source: https://docs.komaa.com/typescript-sdk/plugins/elevenlabs Answer Microsoft Teams calls with an ElevenLabs agent, using the StandIn TypeScript SDK. `@komaa/standin-sdk/elevenlabs` puts [ElevenLabs](https://elevenlabs.io/docs/agents-platform/overview) on a real Microsoft Teams call. StandIn answers the call and dials your worker; this plugin answers that dial, opens one session per call, and relays the audio both ways. ## Install and run There is nothing to install beyond the SDK. ElevenLabs is reached over an ordinary WebSocket, so this plugin adds no dependency. ```bash theme={null} npm install @komaa/standin-sdk ``` ```bash theme={null} export STANDIN_SECRET=... # your StandIn connection secret export ELEVENLABS_API_KEY=... export ELEVENLABS_AGENT_ID=... npx standin-elevenlabs ``` Expose port `9442` at the `/msteams/calling` path and register the public `wss://` URL as your StandIn identity's agent voice URL. The full walkthrough is in the [Quickstart](/typescript-sdk/quickstart), and there is a runnable example at [examples/elevenlabs-msteams-connector](https://github.com/komaa-com/standin/tree/main/examples/elevenlabs-msteams-connector). ## The audio format In the ElevenLabs dashboard, set the agent's input **and** output audio format to `pcm_16000`. An agent set to anything else is refused at the first frame rather than producing a whole call of garbled audio. ## What the agent can do about the call Declare these as **client tools** on the agent. Nothing to implement: the plugin answers them. `clientTools()` returns the declarations, so there is nothing to retype into the dashboard: ```ts theme={null} import { clientTools } from "@komaa/standin-sdk/elevenlabs"; console.log(JSON.stringify(clientTools(), null, 2)); ``` | Tool | Parameters | What it does | | ------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `end_call` | none | Hang up. | | `express` | `emotion` | Set the avatar's expression on the bot's tile. See [The avatar](/typescript-sdk/avatar#the-emotion-the-face-wears). | | `show_image` | `url` **or** `dataBase64` + `mime`, plus `caption?` and `durationMs?` | Put a picture on the bot's video tile. | | `look` | `source?`, `question?` | Look at the caller's screen share or camera. | | `look_back` | `question?` | Look again at something the caller has already moved past. Recorded calls only. | `show_image` is widened here, because ElevenLabs is the one provider that can hand you the bytes inline: either form will do, so neither argument is required. A URL is chosen by the model, which the caller steers, so it is fetched through the SDK's guard: public hosts only, no private or link-local addresses, and the address is re-checked at connect time to close the DNS rebind window. **The fullscreen or overlay choice is not selectable on this plugin.** The other provider plugins route `show_image` through `CallTools.dispatch`, which honours a `display` parameter. This one has its own dispatch, and it reads `mode`. `clientTools()` still emits `display` with its `fullscreen` / `overlay` enum, because that comes from the shared tool spec, so the declaration you paste will offer the model a choice that is then dropped in silence. Deleting the `display` property from the pasted declaration changes nothing except that the model stops being offered it, which is the honest version. Either way the picture goes up with the default placement. [Fullscreen or beside your face](/typescript-sdk/vision#fullscreen-or-beside-your-face) covers what the two placements are for. `look` and `look_back` upload the frame into the ElevenLabs conversation, which **stores** the caller's screen with a third party. They therefore work only while the Microsoft Teams call is being recorded. When it is not, the agent is told why rather than being left with silence. ## Inside your own worker The plugin is a `CallHandler` like any other: ```ts theme={null} import { CallServer } from "@komaa/standin-sdk"; import { ElevenLabsHandler, elevenLabsConfigFromEnv, } from "@komaa/standin-sdk/elevenlabs"; const config = elevenLabsConfigFromEnv(); const server = new CallServer({ handlerFactory: () => new ElevenLabsHandler(config), }); await server.start(); ``` This handler takes its configuration as a **positional** argument, where the other provider plugins take an options object. Read it **once**, then close over it: `handlerFactory` runs once per call, so `new ElevenLabsHandler()` with nothing passed reads the environment again on every call, and a key removed after startup would fail the next caller rather than failing you. `serve()`, which is what `npx standin-elevenlabs` runs, does exactly this, then waits for `SIGINT` or `SIGTERM` before calling `server.aclose()`. `server.start()` returns as soon as the listener is bound, so a script that ends there exits before a single call arrives, and `aclose()` is what drains live calls and releases the port. ## Configuration | Variable | What it is | | --------------------- | -------------------------------------------------- | | `ELEVENLABS_API_KEY` | Your API key. Never logged, never sent to StandIn. | | `ELEVENLABS_AGENT_ID` | Which agent answers the call. | | Variable | What it is | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ELEVENLABS_FIRST_MESSAGE` | Override the opening line. Applied only when the agent's security settings allow the override. | | `ELEVENLABS_ENVIRONMENT` | For an agent deployed to a named environment. | | `ELEVENLABS_AGENT_BRANCH_ID` | Answer with a specific branch. | | `ELEVENLABS_HOST` | Defaults to `api.elevenlabs.io`. Must be an elevenlabs.io host. | | `ELEVENLABS_LOG_TRANSCRIPTS` | Exactly `true` to log turns. Gated a second time on the call being recorded: a transcript in your logs is a recording of the caller that they did not agree to. | The configuration is read once when the worker starts, not per call, so a missing key stops the worker at startup rather than surprising the first caller. `ELEVENLABS_HOST` is checked against the elevenlabs.io suffix, because your API key and agent id travel to it: a wrong host would be credential leakage rather than a failed call. The reasoning behind that shape is on [Configuration](/typescript-sdk/configuration). ## Next The startup buffer, the echo guard and barge-in this plugin sits on. What `look`, `show_image` and `express` reach. # LiveKit plugin Source: https://docs.komaa.com/typescript-sdk/plugins/livekit Answer Microsoft Teams calls with a LiveKit agent, using the StandIn TypeScript SDK. `@komaa/standin-sdk/livekit` puts a [LiveKit agent](https://docs.livekit.io/agents/) on a real Microsoft Teams call. StandIn answers the call and dials your worker; this plugin answers that dial, creates one room per call, dispatches your agent into it, and relays the audio both ways. The shape is the opposite of the provider plugins. Those are a socket you send audio to. LiveKit is a room you **join**: the caller is published as a track, and the agent's audio is subscribed to. Your agent therefore needs no Microsoft Teams awareness at all. It sees a participant who talks. ## Install and run This is the one plugin with dependencies of its own, because joining a room means running LiveKit's client. ```bash theme={null} npm install @komaa/standin-sdk @livekit/rtc-node livekit-server-sdk ``` ```bash theme={null} export STANDIN_SECRET=... # your StandIn connection secret export LIVEKIT_URL=wss://... export LIVEKIT_API_KEY=... export LIVEKIT_API_SECRET=... export LIVEKIT_AGENT_NAME=standin-msteams npx standin-livekit ``` Both packages are **optional peers**: they are imported only when this module is reached, so the core and every other plugin install without them. A missing one raises an error naming the install that fixes it, rather than a bare module-not-found from inside somebody else's package. 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](/expose) carries the mount command and the probes, and there is a runnable example, in both languages, at [examples/livekit-msteams-connector](https://github.com/komaa-com/standin/tree/main/examples/livekit-msteams-connector). ## What your agent receives Job metadata: `source`, `call_id`, `call_direction`, `caller_name`, `user_id`, `tenant_id` and `thread_id`. Room data on two topics: `msteams.context` for participant counts, key presses and recording changes, and `msteams.goodbye` for the closing line. Those key names are a **cross-language contract**, not a local choice, and so are the two topic names. `source` is always `"msteams"`, and it is what Python's [`CallInfo.from_job`](/python-sdk/plugins/livekit#callinfo) matches on: a job whose metadata is missing, unparseable or carries any other `source` reads as "not a Microsoft Teams call" and returns a blank record, rather than failing the worker. So an agent dispatched from this plugin and written in Python sees the caller without a line of translation, and an agent dispatched by something else is not mistaken for a caller. A field StandIn does not know stays **absent** here rather than becoming a shared default, so two anonymous callers never look like one person. There is no `CallInfo` helper on this side, and no entrypoint hook like the Python plugin's `TeamsCall().start(...)`, because there is no LiveKit worker in this process to hook into: your agent is a separate worker that this one dispatches into a room, so it reads the job metadata and subscribes to the two topics with LiveKit's own API. In Python the agent and the plugin share a worker, which is what makes that helper possible there. `user_id` is the caller's AAD object id, and it is **empty for guest and anonymous callers** in both SDKs, so it is omitted here. Never use it as a bare key for per-caller memory without checking it first. **The Python plugin fills an unknown field differently.** It always writes `caller_name`, `tenant_id` and `thread_id`, substituting `"caller"` and `"unknown-tenant"` when StandIn does not know them, where this plugin omits the key. An agent that reads `job.metadata` directly sees `undefined` under this plugin and a literal `"caller"` under the Python one, and one that reads through `CallInfo` sees an empty string here against `"caller"` there. Either way, test for a real name rather than for the key being present. ## Dispatch Set `LIVEKIT_AGENT_NAME` to the name your worker registered with and each call explicitly dispatches that agent into its room. Leave it unset and the room creation itself assigns the job, which is LiveKit's automatic dispatch. Explicit is the recommended setup: a room that is created with nobody dispatched into it is a caller listening to silence. **The metadata above rides the explicit dispatch.** This plugin attaches it to the `RoomAgentDispatch` it creates, so an agent picked up by automatic dispatch is handed no call metadata at all and sees no caller. The Python plugin additionally writes the same record as room metadata for exactly that path, so this is a real difference between the two, not a documentation gap. Set `LIVEKIT_AGENT_NAME` whenever your agent needs to know who is calling. ## Rooms Room names are `{LIVEKIT_ROOM_PREFIX}{callId}`, defaulting to `msteams-`, with the `callId` reduced to a conservative charset and the whole name capped at 100 characters. That derivation is a contract rather than a detail, so a room created for a given call is the same room whichever shipped plugin created it, and the Python plugin computes it identically. The call id is sanitised before it becomes a room name: it arrives from a decoded URL segment, where a `%2F` would otherwise smuggle a slash into the name. The prefix is one of the few places the two SDKs genuinely differ in **how** it is set: `LIVEKIT_ROOM_PREFIX` here, and the `room_prefix` constructor argument on the Python plugin's `TeamsCallHandler`, which reads no environment variable for it. At teardown the plugin disconnects and deletes the room, so the dispatched agent job ends at once instead of idling out: a job whose room still exists sits there until LiveKit's own empty-room timeout, which is minutes of a worker slot doing nothing. Set `LIVEKIT_DELETE_ROOM=off` to keep the room instead. ## The avatar tile If your agent publishes video of its own, an avatar worker or a rendered face, it is relayed onto the bot's video tile automatically and **on by default**. The caller sees your agent rather than StandIn's avatar. When it does not publish video, [`express` and `sendSpeechMarks`](/typescript-sdk/avatar) still drive StandIn's own avatar, and the caller hears the agent either way. `LIVEKIT_TILE_VIDEO` takes three kinds of value, and the third is the one worth knowing about: | Value | What happens | | --------------- | ---------------------------------------------------- | | unset or `auto` | Relay whichever participant's video the room offers. | | `off` | No relay. The caller sees StandIn's avatar. | | anything else | A participant **identity** to pin the relay to. | Pin an identity when a separate worker publishes the avatar and LiveKit's publish-on-behalf attribute is not set: without a name the relay takes whichever participant published first, which on a busy room is the wrong one. With the attribute set there is nothing to pin, because that is the first thing the plugin looks at, falling back to the agent's own identity. Tracks are matched by kind, never by source. An avatar worker publishes untagged video, so a source filter would pick the right participant and then stream nothing, which is the most expensive way to get this wrong. Frames are paced, latest-wins, and dropped rather than queued when the socket is congested, because both streams share one socket and a caller forgives a dropped frame far more readily than a break in the voice. `LIVEKIT_TILE_VIDEO_FPS` defaults to 12 and is clamped to `MAX_TILE_FPS`, which is 20: a talking head gains nothing above that, and a higher rate only spends local CPU on encoding. A value that is not a whole number above zero is refused when the configuration is read, naming the variable: an operator who typed `twelve` is looking at a setting that is not the one in force, and silence is what makes that take an afternoon to find. Encoding needs the optional `sharp` peer: `npm install sharp`. Without it the relay stays off with one line in the log and **the call is unaffected**. The Python plugin's equivalent is the `tile` extra, `pip install "standin-sdk[tile]"`. ## Staying out of a meeting A 1:1 call is simple: every turn is for your agent. A meeting is not. `wakePhrases` arms the SDK's group gate from the call's own thread: ```ts theme={null} new LiveKitHandler({ wakePhrases: ["assistant", "standin"] }); ``` It is **empty by default**, which leaves the gate inert, so an agent that answers everything keeps answering everything and nothing changes for it. `handler.lastDecision` reports what the gate decided about the caller's last finished turn, and is `undefined` until a turn has been decided and on a call where transcripts never arrive. [Group calls](/typescript-sdk/group-calls) is the page for what the gate decides and why a partial transcript may notice a wake phrase but never decide a turn. ## Inside your own worker ```ts theme={null} import { CallServer } from "@komaa/standin-sdk"; import { LiveKitHandler, liveKitConfigFromEnv } from "@komaa/standin-sdk/livekit"; const config = liveKitConfigFromEnv(); const server = new CallServer({ handlerFactory: () => new LiveKitHandler({ config }), }); await server.start(); ``` Read the configuration **once**, then close over it. `handlerFactory` runs once per call, so `new LiveKitHandler()` with nothing passed reads the environment again on every call, and a variable removed after startup would fail the next caller rather than failing you. `serve()`, which is what `npx standin-livekit` runs, does exactly this, then waits for `SIGINT` or `SIGTERM` before calling `server.aclose()`. `server.start()` returns as soon as the listener is bound, so a script that ends there exits before a single call arrives, and `aclose()` is what drains live calls and releases the port. ## Draining and shutdown This plugin is an ordinary `CallHandler`, so a deployment drains the way every other TypeScript worker does: set `server.draining = true` and new dials are refused with 503 while live calls continue. See [CallServer](/typescript-sdk/call-server#draining). That is the one real difference from the Python plugin, which arms itself inside a LiveKit `AgentServer` and can therefore hook the worker's own drain. There is no worker lifecycle to hook here, because there is no LiveKit worker in this process: your agent is a separate worker that this one dispatches into a room. ## Writing the agent in Python instead [`standin.plugins.livekit`](/python-sdk/plugins/livekit) does the same thing and additionally arms itself from inside your worker, so your agent file keeps the shape of every other LiveKit example and nothing starts except through `cli.run_app(server)`. ## Configuration | Variable | Default | What it is | | ------------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `STANDIN_SECRET` | *(required)* | Connection secret from the StandIn portal. | | `STANDIN_PORT` | `9442` | Port the call listener binds. | | `STANDIN_HOST` | `0.0.0.0` | Bind address. Use `127.0.0.1` when only a local tunnel should reach it. | | `STANDIN_WS_PATH` | `/msteams/calling` | Path StandIn dials. | | `LIVEKIT_URL` | *(required)* | Your project's `wss://` URL. | | `LIVEKIT_API_KEY` | *(required)* | Project key. | | `LIVEKIT_API_SECRET` | *(required)* | Project secret. Never logged, never sent to StandIn. | | `LIVEKIT_AGENT_NAME` | *(unset)* | The name your worker registered with. Unset means automatic dispatch. | | `LIVEKIT_ROOM_PREFIX` | `msteams-` | Prepended to the call id to name the room. | | `LIVEKIT_DELETE_ROOM` | *(on)* | Set to `off` to keep the room at teardown instead of deleting it. | | `LIVEKIT_TILE_VIDEO` | `auto` | Relay your agent's own video onto the bot's tile. See [The avatar tile](#the-avatar-tile). | | `LIVEKIT_TILE_VIDEO_FPS` | `12` | Frames per second for that relay. Clamped to `MAX_TILE_FPS`, which is 20. A value that is not a whole number above zero is refused, naming the variable. | A missing required variable is caught when the configuration is read, with one error naming the variable, rather than on the first real call with a caller already on the line. ## Next The rest of the avatar surface: expression, visemes and your own frames on the tile. What the wake phrase gate decides, and what stops playback. # OpenAI Realtime plugin Source: https://docs.komaa.com/typescript-sdk/plugins/openai Answer Microsoft Teams calls with an OpenAI Realtime model, using the StandIn TypeScript SDK. `@komaa/standin-sdk/openai` puts [OpenAI Realtime](https://platform.openai.com/docs/guides/realtime) on a real Microsoft Teams call. StandIn answers the call and dials your worker; this plugin answers that dial, opens one session per call, and relays the audio both ways. ## Install and run There is nothing to install beyond the SDK. OpenAI Realtime is reached over an ordinary WebSocket, so this plugin adds no dependency. ```bash theme={null} npm install @komaa/standin-sdk ``` ```bash theme={null} export STANDIN_SECRET=... # your StandIn connection secret export OPENAI_API_KEY=... npx standin-openai ``` Expose port `9442` at the `/msteams/calling` path and register the public `wss://` URL as your StandIn identity's agent voice URL. The full walkthrough is in the [Quickstart](/typescript-sdk/quickstart), and there is a runnable example at [examples/openai-msteams-connector](https://github.com/komaa-com/standin/tree/main/examples/openai-msteams-connector). ## The audio format The Realtime API speaks PCM at 24 kHz and a Microsoft Teams call speaks 16 kHz. This plugin owns that conversion in both directions, using the same resampler the Python SDK uses, driven by the same shared conformance vectors. You never see it. `OPENAI_VAD_TYPE` decides when the model thinks you have finished: `semantic_vad`, the default, listens for a finished thought, and `server_vad` listens for silence. Semantic interrupts less often mid-sentence. Any other value is refused when the configuration is read, naming the variable, rather than quietly falling back. ## What the agent can do about the call Declared automatically when the call starts, so there is nothing to set up on the provider's side. | Tool | Parameters | What it does | | ------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `end_call` | none | Hang up. | | `express` | `emotion` | Set the avatar's expression on the bot's tile. See [The avatar](/typescript-sdk/avatar#the-emotion-the-face-wears). | | `show_image` | `url`, `caption?`, `display?` | Put a picture on the bot's video tile. | | `look` | `source?`, `question?` | Look at the caller's screen share or camera. | | `look_back` | `question?` | Look again at something the caller has already moved past. Recorded calls only. | `show_image` fetches the URL through the SDK's guard: public hosts only, no private or link-local addresses, and the address is re-checked at connect time. `display` is `fullscreen` or `overlay`, and this plugin routes the call through `CallTools.dispatch`, so the model's choice is honoured. See [Vision and the avatar](/typescript-sdk/vision) for what each placement is for, and [Sending a picture](/typescript-sdk/sending-pictures) for putting a picture into chat instead. The Realtime model hears but does not see, so `look` needs a vision model of your choosing. Set `STANDIN_VISION_API_URL` and `STANDIN_VISION_MODEL` to any OpenAI-compatible endpoint that accepts images, including one you run yourself. The frame is sent for inference and **not stored**, and only the description comes back. Without it the agent is told plainly that looking is unavailable, rather than being left with silence. The whole surface is on [Vision and the avatar](/typescript-sdk/vision). Pass a `describer` in the handler options to point this plugin at a different vision model from the one the environment names, which is how one worker answers two identities with two vision budgets. Build it with `FrameDescriber.fromEnv()` or its constructor, both on [Giving a voice model eyes](/typescript-sdk/vision#giving-a-voice-model-eyes). ## Tools of your own A custom tool runs in **your** worker, so it can reach whatever your worker can reach: ```ts theme={null} import { CallServer } from "@komaa/standin-sdk"; import { OpenAIHandler, openAIConfigFromEnv, type CustomTool, } from "@komaa/standin-sdk/openai"; const tools: CustomTool[] = [ { name: "open_ticket", description: "Open a support ticket for the caller.", parameters: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"], }, // ctx.call is the live call; ctx.recording says whether it is being recorded. handler: async (params, ctx) => `opened ticket for ${String(params.summary)}`, }, ]; const config = openAIConfigFromEnv(); const server = new CallServer({ handlerFactory: () => new OpenAIHandler({ config, tools }), }); await server.start(); ``` The `description` is what the model reads to decide whether to call it, so write it for a model rather than for a developer. Keep the handler fast: the caller is waiting in silence while it runs. A custom tool may not shadow a built-in one. That is refused when the handler is built rather than at the first call, because a shadowed `end_call` is an agent that has quietly lost the ability to hang up. ### A remote MCP server An `mcpTools` entry is dialled by OpenAI itself, so nothing runs in your worker at call time: ```ts theme={null} import { CallServer } from "@komaa/standin-sdk"; import { OpenAIHandler, mcpTool, openAIConfigFromEnv } from "@komaa/standin-sdk/openai"; const config = openAIConfigFromEnv(); const mcpTools = [ mcpTool({ server_label: "docs", server_url: "https://mcp.example.com" }), ]; const server = new CallServer({ handlerFactory: () => new OpenAIHandler({ config, mcpTools }), }); await server.start(); ``` `mcpTool` requires `server_label` and `server_url` and throws naming them if either is missing, so a typo stops the worker at startup rather than producing a session whose tool list is quietly one short. Approval defaults to never, because a live voice call has no approval interface and a pending approval is a caller listening to silence. Pass `require_approval` in the entry yourself to override it: the defaults are spread first, so anything you set wins. ## Inside your own worker The plugin is a `CallHandler` like any other: ```ts theme={null} import { CallServer } from "@komaa/standin-sdk"; import { OpenAIHandler, openAIConfigFromEnv } from "@komaa/standin-sdk/openai"; const config = openAIConfigFromEnv(); const server = new CallServer({ handlerFactory: () => new OpenAIHandler({ config }), }); await server.start(); ``` Read the configuration **once**, then close over it. `handlerFactory` runs once per call, so `new OpenAIHandler()` with nothing passed reads the environment again on every call, and a key removed after startup would fail the next caller rather than failing you. `serve()`, which is what `npx standin-openai` runs, does exactly this, then waits for `SIGINT` or `SIGTERM` before calling `server.aclose()`. `server.start()` returns as soon as the listener is bound, so a script that ends there exits before a single call arrives, and `aclose()` is what drains live calls and releases the port. ## Configuration | Variable | Default | What it is | | ---------------- | ------------ | -------------------------------------------------- | | `OPENAI_API_KEY` | *(required)* | Your API key. Never logged, never sent to StandIn. | | Variable | Default | What it is | | ---------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `OPENAI_REALTIME_MODEL` | `gpt-realtime` | Which Realtime model answers. | | `OPENAI_REALTIME_HOST` | `api.openai.com` | Must end `.openai.com` or `.azure.com`. The Azure allowance is what makes an Azure OpenAI deployment work here at all. | | `OPENAI_VOICE` | *(the model's own)* | Override the voice. | | `OPENAI_INSTRUCTIONS` | a short spoken-reply prompt | The agent's base prompt. Exported as `DEFAULT_INSTRUCTIONS` if you want to extend rather than replace it. | | `OPENAI_VAD_TYPE` | `semantic_vad` | `server_vad` or `semantic_vad`. Anything else is refused when the configuration is read. | | `OPENAI_TRANSCRIPTION_MODEL` | *(unset)* | Ask for transcripts alongside the audio. | | `OPENAI_LOG_TRANSCRIPTS` | *(off)* | Exactly `true` to log turns. Gated a second time on the call being recorded. | The configuration is read once when the worker starts, not per call, so a missing key stops the worker at startup rather than surprising the first caller. `OPENAI_REALTIME_HOST` is checked against those two suffixes, because your API key travels to it: a wrong host would be credential leakage rather than a failed call. The reasoning behind that shape is on [Configuration](/typescript-sdk/configuration). ## Next The startup buffer, the echo guard and barge-in this plugin sits on. What `look`, `show_image` and `express` reach. # OpenClaw plugin Source: https://docs.komaa.com/typescript-sdk/plugins/openclaw Connect the realtime voice provider configured in OpenClaw to Microsoft Teams calls through StandIn. `@komaa/standin-sdk/openclaw` connects the realtime voice provider configured in [OpenClaw](https://openclaw.ai) to Microsoft Teams calls through StandIn. **Scope.** This plugin covers the call path and the voice: a caller converses with the realtime provider OpenClaw is configured with, with barge-in, an echo guard and a recording gate. OpenClaw's agent consultation, tools, skills and memory are reached from chat today, not from the realtime session this plugin drives. [Scope](#scope) has the whole boundary in one list. ## This is a plugin, not a worker you run There is no `node app.js` here, and there is not supposed to be. The plugin loads **inside** the OpenClaw gateway process, declared by its `openclaw.plugin.json`, because it consumes the host's realtime speech-to-speech session, its provider registry and its logger, all of which are in-process objects. OpenClaw's external HTTP surface is text-only, so a worker that only called that HTTP surface could only use OpenClaw as a text brain and bring its own STT and TTS, which is exactly the part this plugin exists to avoid. The plugin registers one host-managed background service, so OpenClaw's own lifecycle starts the call listener at boot and tears it down on shutdown or reload. There is nothing to supervise separately. ## Install OpenClaw 2026.6.10 or newer. Install the SDK where your gateway can reach it: ```bash theme={null} npm install @komaa/standin-sdk ``` `openclaw` is an **optional peer** dependency, never a bundled one: the plugin runs inside the gateway and consumes the host's own realtime session, provider registry and logger. OpenClaw does not load a plugin by package specifier. It is handed a **directory** through `plugins.load.paths` and discovers what is inside it, so point it at the one inside the package: ```json theme={null} { "plugins": { "load": { "paths": [ "node_modules/@komaa/standin-sdk/dist/plugins/openclaw" ] } } } ``` ## Configure and restart Merge this into `~/.openclaw/openclaw.json`, keeping the `plugins.load.paths` entry above: ```json theme={null} { "plugins": { "entries": { "standin-msteams": { "enabled": true, "config": { "secret": "sk_standin_REPLACE_ME", "inboundPolicy": "allowlist", "allowFrom": [ "00000000-0000-0000-0000-000000000000" ], "inboundGreeting": "Hi, this is your assistant. How can I help?", "callingPort": 9442, "bindAddress": "127.0.0.1", "path": "/msteams/calling", "maxConcurrentCalls": 4, "requireRecordingStatus": false, "sessionScope": "per-call", "meetingRecap": false, "realtime": { "provider": "openai", "providers": { "openai": { "apiKey": "sk-REPLACE_ME", "voice": "alloy" } }, "instructions": "You are on a Microsoft Teams call. Keep answers short and spoken." } } } } } } ``` Then restart the OpenClaw gateway, so the plugin's background service starts the call listener: ```bash theme={null} openclaw gateway ``` Now 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](/expose) 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. ## Configuration reference | Key | Meaning | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `enabled` | `false` turns the plugin off without uninstalling it. Anything else, including omitting it, leaves it on. | | `secret` | The StandIn connection secret. Without it nothing starts. | | `callingPort` / `bindAddress` / `path` | Where the call listener binds. Defaults `9442`, `0.0.0.0`, `/msteams/calling`. | | `maxConcurrentCalls` | The operator's cap, default 4. Call 5 is refused with `busy`. | | `inboundPolicy` / `allowFrom` | Who may call. **Defaults to `disabled`, which refuses everyone.** | | `inboundGreeting` | Spoken on pickup. Omit it and the agent waits for the caller to speak first. | | `requireRecordingStatus` | Hold caller media until Microsoft Teams reports recording active. | | `sessionScope` | Memory of the agent session that writes the minutes: `per-call` (default), `per-thread`, or `per-aad`. The voice session is always per call. | | `meetingRecap` | After the call ends, post minutes into the Microsoft Teams chat. Off unless exactly `true`. Needs the StandIn chat lane ([Tiers](/tiers#the-standin-ai-teammate-app)) and a summarization consult. Best-effort: hang-up does not await the post. | | `realtime.provider` / `realtime.providers` | Provider selection and its credentials. | | `realtime.instructions` | The system prompt for the call. | | `realtime.suppressInputDuringPlayback` | Echo guard on or off. On by default. | | `realtime.echoSuppressionWindowMs` | How long after our own audio to keep guarding. Default 600. | | `realtime.echoBargeInRms` | How loud caller input must be to break through the guard. Default 0.04. | The plugin fails closed on the secret. A non-string value coerces to empty rather than to `"[object Object]"`, so an unresolved secret reference stops the listener starting instead of arming it with a guessable value. ## Who may call `inboundPolicy` takes `disabled`, `allowlist`, `pairing` or `open`, and an unset or unknown policy **refuses**. Defaulting the other way would mean a config typo silently opens your agent to anyone who can reach the number. `allowFrom` takes the caller's **AAD object id**, not their email. Copy it from `session.start.caller.aadId` on an inbound call (a refused call still logs it). Emails never match. It matches on exact id, case-insensitive, or on digits-only for a phone number. The id match is what lets a Microsoft Teams caller be allowlisted at all: their id is an AAD object id, and phone normalization would reduce it to the empty string and match nothing. An AAD object id is empty for guest and anonymous callers, so an anonymous caller can never be allowlisted. That is the intended behaviour. `pairing` is enforced as a plain allowlist on calls: there are no pairing codes, expirations or approval prompts on this path, so the fix for a refused caller is the same as under `allowlist`. ## How a call flows ```text theme={null} caller 16 kHz --> 24 kHz --> OpenClaw realtime session model 24 kHz --> 16 kHz --> the caller ``` StandIn dials `wss:///msteams/calling/{callId}` once per call. The SDK answers that dial and speaks the wire protocol; the plugin resamples both legs, guards against echo, and handles barge-in. It carries the three things that make a call feel right rather than merely work: `cancelPlayback()` runs **before** the response is cancelled upstream, so the caller stops hearing the turn they interrupted rather than listening to the rest of it play out. There are two triggers. The model truncating its own turn because it heard the caller, and a deterministic verbal interrupt matched in code: "stop", "hold on", "never mind". The second exists because the model is mid-generation when one arrives, and matching the phrase ourselves is what makes the cut feel instant. It only fires while the agent is actually still speaking, because otherwise "stop" is just a word in a sentence. On a speakerphone the agent's own voice comes back up the caller leg loudly enough for a realtime model's VAD to answer itself, which produces a call where the agent greets, interrupts itself and re-greets while the caller has said nothing. Caller input is dropped while our own audio is still playing out, plus a short window after it, unless it is loud enough to be a real interruption. Tune with `echoSuppressionWindowMs` and `echoBargeInRms`, or turn it off entirely with `suppressInputDuringPlayback: false`. With `requireRecordingStatus` on, no caller media reaches the model until Microsoft Teams reports recording active. This is a Microsoft Media Access API obligation rather than a preference: a bot that processes call media before `updateRecordingStatus` goes active is out of policy. It is off by default because the hosted service can be configured either way, and a gate nobody asked for is a call of silence. ## Refusal reasons The plugin refuses a call in four places, which map to three reason strings. Each one is handed to StandIn rather than dropping the socket, so the refusal is visible on the call instead of appearing as silence. | Reason | Meaning | | ---------------------- | -------------------------------------------------------------------- | | `realtime-unavailable` | No realtime voice provider resolved, or the provider connect failed. | | `not-allowed` | `inboundPolicy` rejected this caller. | | `busy` | At `maxConcurrentCalls`. | Policy and capacity are checked before the call slot is taken, so a refused caller cannot consume capacity by dialling repeatedly. ## Checking it works The gateway logs one line at boot when the listener is up: ```text theme={null} standin-msteams: listening on 0.0.0.0:9442/msteams/calling (max 4 concurrent) ``` If instead it says nothing started, the `secret` is missing or unresolved. If it warns that no realtime voice provider resolved, every call will be refused with `realtime-unavailable`, so set the provider's API key. That warning names the configured provider id when there is one, which separates "the key is missing" from "no provider is configured at all". The provider is resolved **once, when the service starts**, not per call. Adding or fixing an API key therefore needs the gateway restarted or the plugin reloaded before the next call sees it. The boot line above is the confirmation that it did. ## Scope What this plugin covers, stated once so you can design around it: * **The call and the voice.** A realtime speech-to-speech session, barge-in, the echo guard, the recording gate and the inbound policy. That is the whole of what a caller talks to. * **OpenClaw agent consultation, tools, skills and memory** are reached from chat, not from the realtime session this plugin drives. The prompt the caller talks to is `realtime.instructions`, which is this plugin's own configuration rather than the agent's. * **Chat and messages** belong to the SDK's own [chat lane](/typescript-sdk/chat), which is a separate surface with its own page. When `meetingRecap` is on, this plugin opens that lane listen-only so it can post minutes without answering chat. * **Meeting recap** is this plugin's `meetingRecap` switch. Off by default. See [Meeting recap](#meeting-recap) below, and the library it calls in [Meeting recap](/typescript-sdk/minutes). * **Vision, avatar expression and visemes** are on the StandIn call wire and in the SDK core, and this plugin does not wire them into the OpenClaw realtime session. That is a boundary of this plugin, not of the call. Reach for `VisionTools` and `CallTools` from a handler of your own: [Vision and the avatar](/typescript-sdk/vision) and [The avatar](/typescript-sdk/avatar). * **Providers** are the realtime speech-to-speech ones OpenClaw resolves, and the plugin consumes the host's own registry rather than carrying a second list. 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. ## Meeting recap Off until you set `meetingRecap: true`. The plugin then: 1. Collects the call transcript. 2. Writes a restart-recoverable local spool before hang-up returns (`STANDIN_RECAP_DIR`, else `STANDIN_STATE_DIR/recap`, else `~/.standin/state/recap`). A process restart drains what was left if that directory survives the process. Treat recap as best-effort unless that directory is on storage that survives a process or container restart. The file stays until posted, expired (24 hours), or the retry limit (8). 3. Opens its own **listen-only** [chat lane](/typescript-sdk/chat) and posts minutes on it. That lane never answers chat, even if something else on the host already does. The host must provide an outbound chat lane and a summarization consult. Hang-up does not wait for the consult. A recap that cannot open the chat lane is logged and not posted. Meeting recaps are best-effort. They may be lost if the worker exits during processing. Delivery uses StandIn's outbound chat socket. That socket exists on the **StandIn AI Teammate app** ([Tiers](/tiers)). A bring-your-own-bot identity answers chat through its Azure Bot messaging URL, not this socket, so this switch will not post there. Put `STANDIN_RECAP_DIR` on persistent storage when recap reliability matters. A directory that dies with the process loses unfinished minutes. `sessionScope` keys the **consult that writes the minutes**, not the voice session. Voice is always one session per call. # Quickstart Source: https://docs.komaa.com/typescript-sdk/quickstart From one npm install to a Microsoft Teams call your Node.js worker answers. This page gets a Node.js worker answering a real Microsoft Teams call. It uses the echo plugin first, on purpose: if the echo answers, your secret, your tunnel and your StandIn identity are all correct, and anything that breaks afterwards is your agent rather than the transport. ## Prerequisites * Node.js 20 or newer * A StandIn identity and its connection secret, from [standin.komaa.com](https://standin.komaa.com) * A way to expose one port over HTTPS: a tunnel on your laptop, or an ingress in a cluster ```bash theme={null} npm install @komaa/standin-sdk ``` One package. The echo plugin and the `standin-echo` command come with it. ```bash theme={null} STANDIN_SECRET="your-StandIn-connection-secret" \ STANDIN_HOST="127.0.0.1" \ npx standin-echo ``` One line tells you the listener is up: ```text theme={null} standin: answering Microsoft Teams calls on 127.0.0.1:9442/msteams/calling ``` `STANDIN_HOST=127.0.0.1` binds to loopback because a tunnel is the only thing that needs to reach it on a laptop. In a container behind an ingress, leave it unset and the default `0.0.0.0` applies. The upgrade is HMAC-authenticated either way. StandIn dials `wss:///msteams/calling/{callId}`, so the public URL must terminate TLS and forward WebSocket upgrades on that path. The path has to match, because that is where the per-call `callId` segment is appended. [Expose your agent](/expose#mount-the-call-path) has the mount command for Tailscale Funnel, ngrok, cloudflared and devtunnel, and the probes that tell a registered mount from a working one. It is the only page that carries those commands, because three divergent spellings of one URL across three pages is what caused a live incident here. Expose only that path. Anyone who reaches the port still needs a valid signature inside the freshness window, so this is defence in depth rather than the authentication itself, but there is no reason to publish more surface than one route. In the StandIn portal, set the identity's agent voice URL to the public `wss://` URL including the `/msteams/calling` path. Then call the identity from Microsoft Teams and talk. You should hear yourself. ## What just happened ```mermaid theme={null} sequenceDiagram participant T as Microsoft Teams participant SI as StandIn participant W as Your worker T->>SI: caller dials the identity SI->>W: WSS upgrade to /msteams/calling/{callId}, signed W-->>SI: 101, handshake verified once SI->>W: session.start W->>W: onStart(session) SI->>W: audio.frame (PCM16 16 kHz, continuously) W->>SI: audio.frame (your reply) SI->>W: session.end W->>W: aclose(reason) ``` ## Replace the echo with your agent The echo plugin is about 70 lines, and the shape of it is the shape of every plugin. Copy it and change `onCallerAudio`: ```ts theme={null} import { CallServer, type CallSession } from "@komaa/standin-sdk"; class MyHandler { #call: CallSession | undefined; async onStart(session: CallSession) { this.#call = session; // Join a room, open a realtime socket, build an agent. Caller audio does not // flow until this resolves, so a slow start delays the caller instead of // dropping frames. } async onCallerAudio(pcm: Buffer) { // PCM16, 16 kHz, mono, little-endian. Hand it to your agent. const reply = await myFramework.respond(pcm); await this.#call?.sendAudio(reply); } async onGoodbye(text: string) { // StandIn is ending the call and wants this line spoken first. } async aclose(reason: string) { // Always called exactly once, on every path. } } const server = new CallServer({ handlerFactory: () => new MyHandler() }); await server.start(); ``` If your agent speaks at 24 kHz, which every realtime speech-to-speech model does, do not write your own resampler. Read [Audio](/typescript-sdk/audio) first: the SDK ships the resampler and the frame aligner because getting the frame boundary wrong clips the end of every turn. ## Configuration Everything reads from the environment, and `CallServerOptions` overrides any of it in code. | Variable | Default | Meaning | | --------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | | `STANDIN_SECRET` | none, required | The connection secret from the portal. Without it the server refuses to construct. | | `STANDIN_PORT` | `9442` | Port the call listener binds. | | `STANDIN_HOST` | `0.0.0.0` | Bind address. | | `STANDIN_WS_PATH` | `/msteams/calling` | Path StandIn dials. | | `STANDIN_CHAT_URL` | `wss://teams.standin.komaa.com/api/chat/channel` | The [chat lane](/typescript-sdk/chat) endpoint. | | `STANDIN_CHAT_SECRET` | unset, then `STANDIN_SECRET` | The key the chat lane signs with, read **before** `STANDIN_SECRET`. Set it only when your deployment was issued a separate chat credential. | That is the set a first worker needs. [Configuration](/typescript-sdk/configuration) lists every variable the SDK reads and what reads it. ## When it does not answer | Symptom | Cause | | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The process exits at startup naming the secret | `STANDIN_SECRET` is unset. `CallServer` throws rather than start unauthenticated. | | StandIn reports 401 | The secret does not byte-match the portal value, or the worker clock is more than 60 seconds off. | | StandIn reports 404 | The public URL is missing the `/msteams/calling` path segment. | | StandIn reports 503 | The worker is draining, or at `maxConnections`. | | `401 handshake already used` | A correctly signed upgrade was replayed. The single-use guard is doing its job. | | `409 call already has a live session` | A previous call for that `callId` has not finished tearing down, or a retry arrived while the first socket was still open. | | The call connects and ends after 10 seconds | `session.start` never arrived. The pre-start watchdog closed it. | | The call ends after two minutes with `no-agent-answered` | The handler never sent audio and never called `session.markAnswered()`. The usual cause is an agent dispatch that never landed: the socket, the signature and `onStart` were all fine, and nothing arrived to take the call. | | The call ends mid-conversation with `caller-idle-timeout` | No caller audio for 45 seconds. A live call always sends frames, so the far side is gone. | `GET /healthz` on the same port returns `{"ok": true, "calls": }`, which is the cheapest way to confirm the listener is really bound. To go further without placing a call at all, see [Checking the install](/typescript-sdk/checking-the-install). ## Next The seven methods, the session object, and how to refuse a call. The template, and how to turn it into your own plugin. What to do when your agent speaks at 24 kHz. Why a drifting clock turns every call into a 401. # Reaching people Source: https://docs.komaa.com/typescript-sdk/reaching-people Speak into a call that is already up, otherwise ring somebody back and park the line until they answer, in the StandIn TypeScript SDK. Every other lane in this SDK starts with somebody dialling your agent. This one runs the other way: something outside a call wants to reach a person. A scheduled job finished, a chat message asked to be called, a host handed off a task. Which way that lands depends on one thing: whether a call to that person is up right now. If one is, the line is spoken into it. Ringing somebody who is mid sentence with you is the rudest possible way to tell them something. If one is not, a call has to be placed and the line parked, so it is said the moment they answer. `VoiceDelivery` makes that choice, and `LiveCalls` is what it consults. ```ts theme={null} import { LiveCalls, OutboundCaller, OutboundPolicy, PendingMessages, VoiceDelivery, } from "@komaa/standin-sdk"; const live = new LiveCalls(); const delivery = new VoiceDelivery(live, { caller: new OutboundCaller(), policy: OutboundPolicy.fromEnv(), pending: new PendingMessages(), }); const result = await delivery.deliver("Your build finished.", userObjectId); ``` Four pieces, and each answers one question. `live` is which calls are up. `policy` is whether this agent may ring that person at all. `caller` places the call. `pending` is what to say when they pick up. `new OutboundCaller()` reads `STANDIN_SECRET` and refuses to be built without it, because that secret is what signs the request. `STANDIN_WORKER_URL` is the StandIn control address it posts to, which StandIn gives you with your connection secret. Outbound calling is unavailable until it is set, and a URL that is not http or https, has no host, or carries credentials is refused when the caller is built rather than when somebody tries to ring. Every option is optional, and the defaults are the safe ones rather than the convenient ones. With no `policy`, `VoiceDelivery` builds `new OutboundPolicy({})`, whose allowlist is empty, so every call-back is refused. Pass `OutboundPolicy.fromEnv()` or an explicit list. ## Registering a live call A call registers itself, from the handler that owns it, for the life of that call. ```ts theme={null} import { CallServer, LiveCalls, decodeWav, type CallSession, type LiveSpeaker, } from "@komaa/standin-sdk"; class MyHandler implements LiveSpeaker { #live: LiveCalls; #session: CallSession | undefined; constructor(live: LiveCalls) { this.#live = live; } async onStart(session: CallSession) { this.#session = session; this.#live.register(this, session.callId, session.start.threadId); } async say(text: string) { const session = this.#session; if (session === undefined) throw new Error("this call is no longer up"); // Whatever makes this plugin's provider speak. await session.sendAudio(decodeWav(await tts.speak(text))); } async aclose(reason: string) { const session = this.#session; this.#session = undefined; if (session) this.#live.unregister(this, session.callId, session.start.threadId); } } const live = new LiveCalls(); const server = new CallServer({ handlerFactory: () => new MyHandler(live) }); ``` `say(text: string): Promise` is the whole `LiveSpeaker` interface. It belongs to the plugin because only the plugin knows how to make its provider speak without stepping on whatever the agent was already saying. Throw from `say` when the call has gone. A `say` that quietly does nothing resolves, and a resolved `say` is reported as `mode: "live-call"`, so the line is counted as spoken and nobody ever hears it. ### Two keys, one call `register(speaker, callId, threadId = "")` files the speaker under the call id, and under the Microsoft Teams conversation when the call has one. Both, because a delivery addressed by conversation would otherwise miss a call that is only filed by call id, and the agent would place a second call to somebody already on the line with it. Each key is trimmed, and an empty one is skipped. `size` counts keys rather than calls, so a call filed under both counts twice. `deliver` looks the conversation up first and the target second. Whichever key names a live call wins, and `find(...keys)` returns `undefined` when none of them does. ### Unregister removes this speaker's keys, and only this speaker's `unregister(speaker, callId, threadId = "")` deletes a key only when it still points at the speaker that is being removed. That identity check is the point. A second call on the same conversation can start before the first one's teardown runs. A blind delete would then wipe the live call's entry, and every later delivery for that conversation would ring a fresh call. The person would hear a second ring instead of an answer. ### When the live call cannot speak If `say` rejects, the delivery does not stop there. It logs, falls through to the call-back path, and comes back with `mode: "call-back"`. A wedged provider socket would otherwise swallow the message with nobody told. The trade is deliberate: a half-spoken line can be repeated by the call-back, and a repeat beats silence. ## Who the agent may ring `OutboundPolicy` is allow-by-explicit-listing. A directory id must be on the list before the agent can ring it, and an empty list means outbound calling is off. | Option | Default | What it is | | ------------ | ------- | ----------------------------------------------------------------------------------------------------------------- | | `allowed` | empty | Directory ids the agent may ring. Empty means outbound is off. | | `maxPerHour` | `6` | Calls placed in any rolling hour, across all targets. Zero means no cap, which is a choice rather than a default. | `OutboundPolicy.fromEnv()` reads `STANDIN_OUTBOUND_ALLOW`, a comma-separated list of directory ids, and `STANDIN_OUTBOUND_MAX_PER_HOUR`. Unset means off. Matching is case-folded on both sides. A directory id is not case-sensitive, and a case mismatch would read as "not allowed" with nothing to say why. This list is separate from any inbound allowlist, and stricter. Allowing every inbound caller allows no outbound target. The two answer different questions. Inbound asks "may this person talk to the agent?" and the person chose to dial. Outbound asks "may the agent ring this person?" and the agent was talked into it by whoever is on the call. An agent with an outbound tool and no allowlist is an agent that can be talked into cold-calling your directory. Every refusal is one sentence, safe to read out loud: | When | What comes back in `error` | | ---------------- | ---------------------------------------------------------------------------------------------- | | No allowlist | `outbound calling is off: set STANDIN_OUTBOUND_ALLOW to the directory ids this agent may ring` | | Not on it | `that person is not on this agent's outbound allowlist` | | No target at all | `an outbound call needs the person's directory id` | | Over the cap | `this agent has already placed 6 calls in the last hour` | ## The tenant is operator configuration Which organisation a call is placed into comes from the `tenantId` option on `VoiceDelivery`, falling back to `STANDIN_TENANT_ID`. That variable name is exported as `TENANT_ENV`. It never comes from the message, the metadata, the model or the caller. A model is steered by whoever is talking to it, so a model that can choose the tenant is a model that can be talked into dialling a different organisation. With no tenant configured, `deliver` returns `no tenant is configured: set STANDIN_TENANT_ID to place calls` and rings nobody. Who gets rung follows the same reasoning. `CHAT_CALLBACK_TOOL` and `CALL_BACK_TOOL` take a `message` and nothing else: there is no target parameter on them, and there never will be. ### Where the target comes from instead `OutboundLane.rememberChatSender(message)` records who last wrote in a conversation, reading it off the `InboundMessage` itself, and `chatCallbackTarget(conversationId)` hands that back as a `ChatCallbackTarget`. | Field | What it carries | | ---------------- | ----------------------------------------------------------------------------- | | `userObjectId` | The directory id to ring. | | `tenantId` | The organisation that message came from. | | `conversationId` | Where the request came from, so an unanswered call still has somewhere to go. | | `displayName` | What to call them, when the message carried a name. | The message is the only source. Never the message text, never a tool parameter: an agent that can be told who to ring is an agent that can be talked into ringing anybody, and the thing doing the telling is being steered by whoever is on the other end of it. A message with no `senderAadId` is recorded not at all, rather than recorded as an empty target. When nobody has written, or the last message is more than ten minutes old, what comes back is a sentence rather than a target: `I do not know who to call for this conversation.` or `That was a while ago. Ask me again and I can call you.` A sentence rather than a thrown error, because whatever reads this is a tool result a model says out loud. Check which you got before you use it, and TypeScript makes you: the return type is `ChatCallbackTarget | string`. ```ts theme={null} const target = lane.chatCallbackTarget(message.conversationId); if (typeof target === "string") return target; // already safe to read out const placed = await lane.place({ userObjectId: target.userObjectId, text: "Your build finished.", tenantId: target.tenantId, threadId: target.conversationId, }); ``` That ten minute window is `CHAT_CALLBACK_WINDOW_MS`, and the Python twin holds the same ten minutes as `CHAT_CALLBACK_WINDOW_S`, in seconds. The number cannot be copied between the two SDKs, only the duration. ## The hourly budget counts calls that rang `policy.record()` runs after the call is placed and a call id comes back, not when the attempt starts. Counting attempts would let a broken worker burn the whole hour on calls that never rang anybody, and the next real delivery would be refused for an hour because of it. A worker that is down costs nothing against the budget. A phone that actually rang costs one. ## What is parked, and where `PendingMessages` is on disk, and that is the part people get wrong. The leg that answers is **a different call**. You ask for the call in one place, and StandIn dials your worker minutes later with `direction: "outbound"` and a fresh `callId`. A restart between the two is ordinary, and it may not even be the same process. Parked in memory, the message is lost silently: the callee picks up and hears nothing, with no error anywhere. `stateDir()`, at the package barrel here and in `standin.outbound` on the Python side, is `STANDIN_STATE_DIR` when set, otherwise `~/.standin/state`, created owner-only. It is deliberately not a temp directory. A temp directory passes every test and loses every parked message on the next reboot. `VoiceDelivery` parks the record before `deliver` returns, because the leg can be answered before a later park would have run. | Field | What it carries | | ------------- | -------------------------------------------------------------------------------------------------- | | `callId` | The id the answering leg arrives with. | | `text` | The line to speak on answer. | | `threadId` | The conversation the request came from. | | `tenantId` | Which tenant to post the fallback into. Never taken from a model. | | `target` | Who was rung, so a second call to the same person can be refused while the first is still ringing. | | `requestedBy` | Directory id of whoever asked for the call, for the audit trail. | | `createdMs` | Stamped by `park` when the record has no time of its own. | | `attempts` | Delivery attempts so far, so a chat that keeps failing stops. | Popping is atomic. The rename is the lock, so two workers answering the same leg cannot both speak. ```ts theme={null} const parked = pending.pop(session.callId); if (parked !== undefined) await this.say(parked.text); ``` Nothing should be said while the leg is still ringing, and there is no "they picked up" message on the wire. `OutboundLane.attach(session, speak)` holds the record, treats recording going active as the answer, speaks then, and gives the record back if nobody ever picks up. ### The thread id is how an unanswered call still reaches them A call is placed because somebody is owed something. If they do not pick up, they are still owed it. `threadId` is carried on the record for exactly that: it is the conversation the request came from, so the line can be posted there instead of evaporating. `OutboundLane` owns that sweep, and it posts once even when both the ring timer and the call outcome fire for the same call. `OutboundLane.place` parks no fallback at all for a call that has no real conversation, which is what `callThreadIsPostable` decides. A one-to-one call has no meeting conversation, and posting to what that field carries instead would either fail or reach the wrong place. What does the posting is whatever you passed as `new OutboundLane({ chat })`, and `ChatSender` is the shape it has to have: one `send({ tenantId, conversationId, text, idempotencyKey })` returning a promise of a boolean. `ChatChannel` already satisfies it, so the ordinary case is handing over the channel you already have. The interface exists so that a plugin posting through something else is not forced to wrap one. It is structural in TypeScript and a `Protocol` in Python, and it takes one options object here where the Python twin takes keyword arguments. It returns whether the message went, not whether it was attempted. A `false` puts the record back for another try, and after five attempts, or once the record is older than the lane's `ttlMs`, the lane gives up in the log rather than retrying for ever. The `idempotencyKey` is what makes the ring timer and the call outcome both firing for one call tell the person once. ## The leg that answers Placing a call returns a `PlacedCall`, from `OutboundCaller.placeCall` and from `OutboundLane.place` alike. | Field | What it is | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `callId` | The id the answering leg will arrive with. This is what a message is parked against. | | `scenarioId` | StandIn's own correlation id when it sends one, otherwise an empty string. Log it and a call in your records matches a call in a support question. | `OutboundLane.attach(session, speak)` turns that id back into an `OutboundLeg` on the call that answers, and returns `undefined` on an inbound one, so a plugin calls it unconditionally from `onStart` and forwards two things: ```ts theme={null} class MyHandler { #leg: OutboundLeg | undefined; async onStart(session: CallSession) { this.#leg = lane.attach(session, (message) => this.say(message.text)); } async onContext(text: string) { await this.#leg?.onContext(); } async aclose(reason: string) { await this.#leg?.aclose(reason); } } ``` The session only has to satisfy `OutboundSession`, which is the `callId`, the `direction` on `start`, `recordingActive` and an `end` to call, so a test double is four members rather than a whole call. The leg holds the parked line on `message` and speaks it through the `Speak` you gave `attach`, which owns the wording: only your plugin knows whether its provider takes an instruction or a literal line to say. A leg that finds nothing parked waits a few seconds before concluding there is nothing to say, because the leg can be answered before `place` has finished parking, and a race lost there is a caller who picks up to silence. **Recording going active is what "they answered" means.** There is no "they picked up" message on the wire, and recording turning on is what happens when a Microsoft Teams call is actually connected, so a forwarded `onContext` is the signal. A plugin with a better one calls `answered()` directly. A pickup fast enough to beat the attach is covered too, because arming checks the recording once on the spot rather than only waiting for the next one. **The leg runs its own watchdog**, ending a call nobody answered after the lane's `answerTimeoutMs`, which defaults to `DEFAULT_ANSWER_TIMEOUT_MS` and is two minutes. The idle watchdog cannot do this job: a ringing leg carries no caller audio by definition, so to that watchdog every outbound call looks dead from the moment it starts. **Nothing unsaid is thrown away.** If speaking rejects, the line is not marked spoken, because it was not said. If the leg closes with the line still unsaid, `aclose` releases the record rather than deleting it, and the sweep posts it to chat instead. Somebody was owed something, and not picking up does not stop them being owed it. Every outbound duration here is named in milliseconds and the Python twin names the same duration in seconds: `DEFAULT_ANSWER_TIMEOUT_MS` against `DEFAULT_ANSWER_TIMEOUT_S`, `answerTimeoutMs` against `answer_timeout_s`, `ttlMs` against `ttl_s`. Port the duration, never the number. ## The result ```ts theme={null} const result = await delivery.deliver(text, userObjectId, conversationId); if (!result.ok) return result.error ?? ""; ``` | Field | What it means | | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ok` | Whether the line reached a lane that will say it. | | `mode` | `"live-call"` when it was spoken into a call that was already up, or `"call-back"` when a call was placed and the line parked. Both strings are part of this API. | | `callId` | The call that was placed, on the call-back path. Absent otherwise. | | `error` | One sentence, safe to read out loud. Absent when `ok`. | `deliver` never throws. Whatever reads the result is either a host that marks the whole platform failed on an exception, or a model that says it out loud, and neither can do anything useful with a stack trace. Three refusals come from the placement rather than the policy: | When | What comes back in `error` | | ---------------------- | --------------------------------------------------------- | | No `caller` was passed | `no outbound caller is configured on this deployment` | | The request failed | `could not place the call: ...`, with the reason appended | | No call id came back | `the call was not placed` | An empty line is refused before the registry is even consulted, with `there was nothing to say`. An empty message must never place a real phone call to a real person. # Realtime providers Source: https://docs.komaa.com/typescript-sdk/realtime-providers StartupBuffer and the echo guard for a speech-to-speech agent: what arrives before you are ready, and why the agent answers itself, in the StandIn TypeScript SDK. A realtime speech-to-speech provider is handed the caller's audio and hands audio back, and the turn-taking is theirs. That is the whole reason to use one, and it is why [Turn-taking](/typescript-sdk/voice) is a page you can skip: no segmenter, no paced playback, no pipeline of three vendors to keep in step. This page is the other half of that bargain. The provider owns the turn. It does not own the two things on either side of it: * **what arrived before your provider was ready to take it**, which the provider cannot hold because it did not exist yet * **whether the audio coming back is somebody else's voice or your own**, which the provider cannot know because it only sees one stream Neither is a property of any framework, so neither lives in a plugin. Both are here. ```ts theme={null} import { ECHO_BARGE_IN_RMS, ECHO_SUPPRESSION_WINDOW_MS, MAX_PENDING_AUDIO, MAX_PENDING_CONTEXT, StartupBuffer, pcm16Rms, shouldSuppressEcho, type EchoGuardOptions, } from "@komaa/standin-sdk"; ``` Every name on this page is exported from the package root. There is no deep import for these modules: the package publishes the root and the framework plugin subpaths and nothing else, so `@komaa/standin-sdk` is the import. `pcm16Rms` is also re-exported as `echoPcm16Rms`, which is the same function under an older name. ## What arrives before your agent is ready The call starts the moment StandIn dials. Your agent starts later: a socket has to open, a room has to be joined, a session has to be configured, an agent job has to be dispatched and accepted. `onStart` covers part of that gap for you, because inbound messages are queued while it runs. It does not cover the rest of it. A socket that is open is not a session that is configured, a room you have joined is not an agent that has joined it, and a handler that returns from `onStart` rather than holding the call open has handed itself the same gap deliberately, which is often the right call: `onStartTimeoutMs` is 15 seconds and a caller waiting on a slow provider hears dead air. See [onStart](/typescript-sdk/call-handler#the-seven-methods). Two things land in that window and both matter. **The caller's first words.** People start talking the instant the call connects, and the first thing they say is very often the reason they called. Drop it and the agent opens by asking a question that was already answered, which is the single most common way a demo call goes wrong. **The first context.** The `There are 4 human participants on this call. Stay quiet unless directly addressed.` line and the `The Microsoft Teams call recording is now ACTIVE.` change both land in that same first moment. Drop those and a [group gate](/typescript-sdk/group-calls) never engages, so the agent answers every turn of a meeting, and a recording gate never opens, so an agent that waits to be told the call is recorded waits for the whole call. Neither failure looks like a dropped message. Both look like a feature that was never built. Gate your own storage on `session.recordingActive`, which the SDK keeps current from `session.start` and every later status change, rather than on a sentence you happened to catch. What the buffer protects is the copy that reaches your **provider**, which has no other way to learn either fact. ```ts theme={null} import { StartupBuffer, type CallSession } from "@komaa/standin-sdk"; class MyHandler { readonly #pending = new StartupBuffer(); #agent: Provider | undefined; async onStart(session: CallSession) { this.#agent = await connectToProvider(); await this.#pending.release( (pcm) => this.#agent!.sendAudio(pcm), (line) => this.#agent!.sendContext(line), ); const { audio, context } = this.#pending.dropped; if (audio || context) { console.info("standin: the caller outran the agent starting up"); } } async onCallerAudio(pcm: Buffer) { if (this.#pending.holding) this.#pending.audio(pcm); else this.#agent!.sendAudio(pcm); } async onContext(text: string) { if (this.#pending.holding) this.#pending.context(text); else this.#agent!.sendContext(text); } async aclose(reason: string) { if (this.#pending.holding) this.#pending.discard(); } } ``` | Member | What it does | | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `new StartupBuffer(maxAudio = MAX_PENDING_AUDIO, maxContext = MAX_PENDING_CONTEXT)` | One per call. Each bound is floored at 1. | | `holding` | Whether the agent is still being set up. False after `release` or `discard`. | | `audio(pcm)` | Hold one frame of the caller's voice. An empty buffer is ignored. | | `context(text)` | Hold one line of call context. An empty string is ignored. | | `await release(sendAudio?, sendContext?)` | Hand everything over, in order, and stop holding. Resolves to `{ audio, context }`, the counts released. | | `dropped` | What was lost to the bounds, as `{ audio, context }`. | | `discard()` | Throw it away, for a call that ended before the agent was ready. | Order is preserved within each lane, and **audio is released before context**. The provider needs the caller's words in the order they were said; the context is a note about the call rather than part of the conversation, so it belongs after the words and not interleaved with them. Both callbacks may be sync or async, and each is awaited. A provider's send is often fire-and-forget, and forcing a promise on one that is not is how a plugin ends up wrapping every send in a lambda. Either callback may be omitted, for a lane your provider has no route for: the buffer still drains and still stops holding, it just sends nothing. `release` is safe to call twice: the second call releases nothing and resolves to `{ audio: 0, context: 0 }`. That matters on the failure paths, where `onStart` can end early in more than one place. ## Audio first, context second, oldest dropped first Two caps, both defaults, both overridable per buffer: | Constant | Value | What it is | | --------------------- | ----- | --------------------------------------------------------------------------------------------- | | `MAX_PENDING_AUDIO` | `200` | Frames of the caller's voice. At the wire's 20 ms frame that is **4 seconds** of speech. | | `MAX_PENDING_CONTEXT` | `20` | Lines of call context. Context sentences are rare and each one is small, so this is generous. | Four seconds is enough to hold an opening sentence and far short of enough to hide a socket that never opened. That is the whole calculation. A provider that is going to connect has connected by then; a provider that is not going to connect must not be allowed to accumulate the caller's entire call in memory while it fails to, on every concurrent call the worker is carrying. When the cap is reached, the **oldest** entry goes and the newest is kept. If something has to be lost, lose the stale audio: the words the agent is about to answer are the ones the caller just said, and an agent that opens on a four-second-old fragment is worse than one that opens on the most recent sentence. Log a non-zero `dropped`. It is the one signal that tells you the caller outran your provider's startup, and there is no other place it shows up: the call sounds normal, the agent answers, and the only trace is the sentence nobody accounted for. Treat it as a startup-latency alarm rather than an error. `discard()` exists for the call that ends before the agent is ready. Use it in `aclose` when `holding` is still true, rather than releasing into a provider you are about to close. ## The agent answering itself On a speakerphone, and on any laptop whose user has not got a headset on, the agent's own voice comes out of the speaker and goes straight back into the microphone. It arrives at your handler as caller audio. The provider's voice detection hears a turn, ends it, and replies to it. Then that reply loops back, and it replies to that. The caller is silent throughout and cannot get a word in, and it continues until somebody hangs up. It is not a subtle degradation, it is the call being taken over. **The two SDKs are not the same shape here.** The Python SDK has an `EchoGuard` **class** that owns its own playout clock: you call `note_output`, `collapse` and `mark_caller_turn` on it, and ask `allow_input(rms)` per frame. This SDK has **no class**. It has one pure function, `shouldSuppressEcho`, and the clock is yours to keep. Anything you read about `EchoGuard` applies to the Python SDK only. The differences are tabulated at the end of this page. `shouldSuppressEcho(pcm16k, playbackActiveUntil, opts?)` decides, per inbound frame, whether the caller's audio is withheld from the model. ```ts theme={null} import { FrameAligner, REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ, frameDurationMs, resamplePcm16, shouldSuppressEcho, type CallSession, } from "@komaa/standin-sdk"; class MyHandler { readonly #aligner = new FrameAligner(); #call: CallSession | undefined; #provider: Provider | undefined; /** Epoch ms at which the audio already sent finishes PLAYING. */ #playbackEndAt = 0; /** False until the caller has genuinely spoken once. */ #callerTurnStarted = false; async onCallerAudio(pcm: Buffer) { if (shouldSuppressEcho(pcm, this.#playbackEndAt, { allowBargeIn: this.#callerTurnStarted })) { return; } this.#provider!.appendInput(resamplePcm16(pcm, SAMPLE_RATE_HZ, REALTIME_SAMPLE_RATE_HZ)); } async #onModelAudio(pcm24: Buffer) { const pcm = resamplePcm16(pcm24, REALTIME_SAMPLE_RATE_HZ, SAMPLE_RATE_HZ); for (const frame of this.#aligner.push(pcm)) { await this.#call!.sendAudio(frame); // Duration, not frame count: it is what the guard compares against the clock. this.#playbackEndAt = Math.max(this.#playbackEndAt, Date.now()) + frameDurationMs(frame); } } } ``` The polarity is "should I suppress this", not "may this through". `true` means **drop the frame**. Reading it the other way round mutes the caller for the whole call and passes the echo, which is the exact failure inverted. The function is stateless and takes the raw frame rather than a number: it measures loudness itself with `pcm16Rms`, which returns root-mean-square amplitude normalised to `0.0` to `1.0` and is dependency-free because it runs on every inbound frame of every call. See [Audio](/typescript-sdk/audio). `EchoGuardOptions` is four optional fields, and each one is read fresh on every call, so any of them can change during the call. | Option | Default | What it does | | ----------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------- | | `suppressInputDuringPlayback` | on | Exactly `false` turns the whole guard off. | | `echoSuppressionWindowMs` | `ECHO_SUPPRESSION_WINDOW_MS`, `600` | How long after your audio finishes playing the guard stays armed. | | `echoBargeInRms` | `ECHO_BARGE_IN_RMS`, `0.04` | Loudness an in-window frame must reach to be believed as a real interruption. | | `allowBargeIn` | on | Exactly `false` drops the loudness exception and suppresses every in-window frame. | What the function does, in full: | State | Outcome | | ---------------------------------------------------------- | ------------------------- | | `suppressInputDuringPlayback: false` | Through. | | Outside the playback window | Through. | | In window, `allowBargeIn: false` | Dropped, at any loudness. | | In window, barge-in allowed, RMS below the threshold | Dropped. | | In window, barge-in allowed, RMS at or above the threshold | Through. | `suppressInputDuringPlayback: false` is a real option, not a test hook. On a headset-only deployment there is no acoustic path from the speaker back into the microphone, so the guard has nothing to catch and every frame it filters is a barge-in the caller does not get. Note that only the literal `false` disables it: an `undefined` from an unset config key leaves the guard on, which is the safe way round. ## A playout clock, not a send clock A realtime model streams its answer far faster than realtime. Five seconds of speech can be handed over in a fraction of a second, and a call consumes audio at one second per second, so the rest of it is still buffered and still being heard long after the send returned. So wall-clock send time is useless here. A guard keyed on "did we send something recently" disarms a few hundred milliseconds into a sentence that is still coming out of the caller's speaker, which is the exact moment the echo it exists to catch starts arriving. `playbackActiveUntil` is therefore the estimated **epoch milliseconds at which the audio you have already sent finishes playing**, and you accumulate it yourself, one duration at a time: ```ts theme={null} this.#playbackEndAt = Math.max(this.#playbackEndAt, Date.now()) + frameDurationMs(frame); ``` The `Math.max(..., Date.now())` rather than a bare addition is the part worth reading twice. After a gap in speaking, the old horizon is already in the past, and adding to a stale horizon leaves the clock permanently behind real time, so the guard never arms again for the rest of the call and the failure looks like a guard that was never switched on. Pass `frameDurationMs(frame)` rather than counting frames. Outbound chunk lengths are not fixed, and a frame count drifts against real time in exactly the direction that hurts. It must be `Date.now()`, on the epoch-millisecond scale. `shouldSuppressEcho` compares against `Date.now()` internally, so a horizon built from `performance.now()` sits decades in the past and the guard never fires once. The [group gate](/typescript-sdk/group-calls) takes either clock because it only compares differences; this one does not. ## No barge-in until the caller's first real turn Pass `allowBargeIn: false` until the caller has spoken once, and every in-window frame is dropped however loud it is. That is not a conservative default, it is the specific case. The agent's opening greeting is the first thing on the call, it echoes back, and it is loud, because a greeting is spoken at full volume into a room nobody is talking over yet. Without this rule the loudest possible echo arrives at the exact moment there is no history to compare it against, the agent treats it as a barge-in, interrupts itself, and greets itself again. And again. Flip the flag where you know a real turn happened: on a finished caller transcript, and on a barge-in your provider reported and you accepted. The method below is **your own**, wired to whatever your provider calls a transcript event, and it is what the SDK's own realtime plugin does on its provider's. ```ts theme={null} #onProviderTranscript(role: "user" | "assistant", text: string) { if (role === "user" && text.trim() !== "") this.#callerTurnStarted = true; } ``` Because the flag is an argument rather than state inside a guard object, it latches in your own field and there is nothing to reset. Note that only the literal `false` withholds barge-in: leave the option out and the loudness exception is on, which is the wrong way round for the first few seconds of a call, so set it explicitly from the start. ## Collapse on an accepted barge-in When you accept a barge-in you cancel the playback StandIn still holds. Your horizon does not know that. It counted that audio as if the caller would hear all of it, so it is still sitting several seconds in the future. Left alone, the guard keeps filtering the caller's microphone for the length of the buffer you just threw away, which is precisely the words they interrupted you to say. The caller interrupts, the agent stops, the caller talks, and the agent hears nothing. ```ts theme={null} async #onBargeIn() { await this.#call!.cancelPlayback(); // first: un-send what StandIn still holds this.#aligner.reset(); // the residual belongs to a dead turn this.#playbackEndAt = Date.now(); // the horizon covered audio nobody will hear this.#callerTurnStarted = true; // this was a real turn this.#provider!.cancelResponse(); // then: stop the model generating more } ``` Do this anywhere you call `cancelPlayback()`, which includes `onGoodbye`: a goodbye cancels playback too, and a horizon left standing after one spends the last seconds of the call filtering the caller's reply to it. See [Barge-in](/typescript-sdk/call-handler#cancelplayback-is-the-barge-in). Assigning `Date.now()` is what the SDK's own realtime plugin does, and it leaves the guard armed for one more tail window, `600` ms by default, which is usually what you want: the last already-playing milliseconds are still echoing. The Python SDK's `collapse()` goes further and pulls the horizon to `now - tail_window_ms`, so its guard is disarmed immediately. Subtract `echoSuppressionWindowMs` yourself if you want that exact behaviour. ## The tail belongs to your provider `echoSuppressionWindowMs` is an option and not a hard constant, and that is a deliberate line. It covers the network and the jitter buffer between you and the caller's speaker, and then however long the provider's own voice detector keeps hearing you after the audio stops. The first part is the wire and is the same for everybody. The second is a property of that provider, and providers differ. `600` is the default and the value in `ECHO_SUPPRESSION_WINDOW_MS`. Move it with the failure you actually see: * Too short and the tail of your own sentence gets through as a turn, and the agent answers its own last three words. * Too long and a genuine interruption in the moments after you stop speaking is dropped, and the caller has to say it twice. `echoBargeInRms` is the same kind of knob on the same kind of trade. `0.04` on the `0.0` to `1.0` scale `pcm16Rms` returns. Raise it in a loud room and you buy fewer false barge-ins with a real cost: the case it then gets wrong is a quiet caller trying to interrupt a loud agent, which is the caller who most needs the interruption to work. Tune both per provider, keep them in your plugin's configuration rather than in its code, and treat the defaults as a starting point that was measured on a call rather than a constant that was derived from one. `EchoGuardOptions` is shaped to be spread straight from a config block, with your per-call `allowBargeIn` layered on top: ```ts theme={null} shouldSuppressEcho(pcm, this.#playbackEndAt, { ...this.#config.echo, allowBargeIn: this.#callerTurnStarted, }); ``` ## What is not the same in the Python SDK `StartupBuffer` is the same object in both, with the same caps and the same two lanes. One detail is language-shaped: `dropped` and the resolved value of `release` are objects here, `{ audio, context }`, and tuples there, `(audio, context)`. The echo guard is genuinely different, and a port that assumes otherwise compiles and then fails on a real call. | | Python SDK | TypeScript SDK | | ------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------- | | Shape | `EchoGuard`, a class holding per-call state | `shouldSuppressEcho`, a pure function | | Polarity | `allow_input(...)` is `True` to **pass** the frame | `shouldSuppressEcho(...)` is `true` to **drop** it | | Input | a loudness float you computed yourself | the PCM16 `Buffer`, measured for you | | The playout clock | inside the object, fed by `note_output(duration_ms)` | yours, passed as `playbackActiveUntil` | | Clock source | monotonic milliseconds | epoch milliseconds, from `Date.now()` | | Accepted barge-in | `collapse()`, disarms immediately | assign the horizon yourself, tail window still armed | | First caller turn | `mark_caller_turn()`, a latch on the object | `allowBargeIn`, an option passed per frame | | Tail and threshold | `tail_window_ms`, `barge_in_rms`, set once on the constructor | `echoSuppressionWindowMs`, `echoBargeInRms`, read fresh per frame | | Disabling it | `EchoGuard(enabled=False)` | `suppressInputDuringPlayback: false` | The constants are the same in both and carry the same names, `ECHO_SUPPRESSION_WINDOW_MS` at `600` and `ECHO_BARGE_IN_RMS` at `0.04`, so configuration and tests can name them rather than repeat them. ## Next The gate that keeps the agent out of a meeting it was not addressed in. A full realtime handler, with the resampling, the alignment and the barge-in. The other half: an agent built from separate transcription, model and speech. `frameDurationMs`, `pcm16Rms`, the resampler and the frame aligner. # Security Source: https://docs.komaa.com/typescript-sdk/security The two StandIn HMAC lanes, their replay windows, who may call the agent, and how the TypeScript SDK fails closed. StandIn authenticates with one shared secret and HMAC-SHA256. There are **two lanes**, they sign different things, and they are not interchangeable. | Lane | Signs | Window the SDK enforces | Header | | ------------ | ---------------------------------------------------------------------- | ----------------------------- | ------------------------ | | v1 handshake | `"{timestampMs}.{id}"`, where `id` is the channel name or the `callId` | 60 s, fixed | `X-StandIn-Signature` | | v1 body | `"{timestampMs}."` followed by the exact body bytes | 300 s by default, overridable | `X-StandIn-Signature` | | v2 request | `"{timestampMs}."` followed by `METHOD\npath\nsha256(body)` | 60 s | `X-StandIn-Signature-V2` | The timestamp always travels in `X-StandIn-Timestamp`, in milliseconds. Signatures are lowercase hex. There are three signers, not two. The body signer and the request signer carry different headers and sign different things, and collapsing them loses the distinction that makes v2 worth having. Their windows differ too, and the difference is the one people get wrong. The **body** lane allows 300 seconds because a relay retry is legitimately delayed. The **request** lane is verified inside the 60 second handshake window, because a control request is sent and answered immediately. The only v2 verifier in this SDK is the call-outcome route on `CallServer`, so a worker whose clock is more than a minute out rejects call outcomes exactly as it rejects calls. Both lanes are real and both are correct. The WebSocket lane signs the **channel or call name**; the POST relay signs the **body**. Never present one as a fix for the other, and never "correct" a handshake signer into a body signer. They protect different things. ## Signing helpers ```ts theme={null} import { REPLAY_WINDOW_MS, // 60000 CHAT_REPLAY_WINDOW_MS, // 300000 SIGNATURE_HEADER, // "x-standin-signature" SIGNATURE_V2_HEADER, // "x-standin-signature-v2" TIMESTAMP_HEADER, // "x-standin-timestamp" nowMs, } from "@komaa/standin-sdk"; ``` Import a window rather than retyping `300000`. The header constants hold **lowercase** strings here because Node lowercases incoming header names, while the Python SDK's constants of the same names hold the canonical casing. HTTP header names are case-insensitive, so the wire is identical and only the constant's spelling differs. The four signer and verifier signatures: ```ts theme={null} signHandshake(secret, timestampMs, handshakeId): string verifyHandshake(secret, timestamp, handshakeId, signature, currentMs?): boolean signBody(secret, timestampMs, rawBody): string verifyBody(secret, timestamp, rawBody, signature, currentMs?, windowMs?): boolean canonicalRequest(method, path, rawBody): string // "METHOD\npath\nsha256_hex(body)" signRequest(secret, timestampMs, method, path, rawBody): string ``` `currentMs` exists so a test can pin the clock. `windowMs` on `verifyBody` defaults to `CHAT_REPLAY_WINDOW_MS`. ## Lane 1: the WebSocket handshake ```ts theme={null} import { signHandshake, verifyHandshake, nowMs } from "@komaa/standin-sdk"; // Inbound: StandIn dials your call listener. CallServer does this for you. const ok = verifyHandshake(secret, timestampHeader, callId, signatureHeader); // Outbound: your worker dials the chat channel. ChatChannel does this for you. const signature = signHandshake(secret, nowMs(), "chat"); ``` The payload is the literal string `` `${timestampMs}.${handshakeId}` ``. For an inbound call the `handshakeId` is the `callId` from the URL path, which is why `session.callId` is trustworthy: it is the value the signature covered, not something a caller supplied. A `session.start` body naming a different `callId` is refused, because that is either a bug or an attempt to ride one call's signature into another call's session. For the outbound chat dial the `handshakeId` is the literal channel name, `"chat"`. That is the inbound handshake scheme running in the opposite direction, and it is deliberately not the body signer: the POST relay lane signs the body instead, and the two must not be "fixed" into each other. The window is 60 seconds in both directions. Handshakes are dialed and answered immediately, so anything older is a replay. A worker whose clock has drifted past a minute will see every call rejected with 401, and that is usually what a sudden total 401 means. ## Lane 2: body and request signatures ```ts theme={null} import { signBody, verifyBody, canonicalRequest, signRequest } from "@komaa/standin-sdk"; // v1 body signature: `{timestampMs}.{rawBody}` const sig = signBody(secret, nowMs(), rawBody); const ok = verifyBody(secret, timestampHeader, rawBody, signatureHeader); // v2: binds the method and path as well as a hash of the body canonicalRequest("POST", "/api/calls", rawBody); // "POST\n/api/calls\n" const v2 = signRequest(secret, nowMs(), "POST", "/api/calls", rawBody); ``` Sign the **exact bytes you transmit**. Serialize once, sign that string or buffer, and send the same one. Re-serializing between signing and sending changes key order or whitespace and the signature stops verifying, which is the single most common plugin bug on this lane. v2 is preferred for control requests: binding the method and path means a validly signed body cannot be replayed against a different endpoint. The path is the request path only, without origin or query string. `/api/calls` above is the real one, and [`OutboundCaller`](/typescript-sdk/reaching-people) already signs it for you, so you only reach for `signRequest` when you are writing a control client of your own. ## How both lanes fail closed * Missing secret, timestamp or signature returns `false`. There is no permissive path. * The timestamp must be ASCII decimal. `Number()` alone would accept empty strings, hexadecimal and exponent notation. * Comparison is constant time, with a length check first. `timingSafeEqual` throws on a length mismatch, and this code is reachable by anyone who can open a socket, so an unguarded compare would turn a short signature header into a 500 instead of a 401: both a crash path and an oracle that tells "malformed" apart from "wrong". That length check is this language's guard rather than a rule of the scheme; the Python SDK needs a different one for `hmac.compare_digest`. Both SDKs fail closed, both restrict the timestamp to ASCII decimal, and the shared conformance vectors assert that a signature produced by either verifies in the other, byte for byte. ## The replay guard A correctly signed upgrade replayed inside the freshness window must not open a second socket, so `CallServer` keeps every accepted handshake fingerprint single-use. Two details in that guard are worth knowing, because both were bugs waiting to happen: * The fingerprint uses the **normalized** signature. Verification accepts case and whitespace variants, so keying on the raw header would let the same capture replay once per casing. * Entries are aged by the **signing** timestamp, never by arrival time. Verification accepts a timestamp up to the window in the future, so an entry aged from arrival could be pruned while its signature was still valid, reopening the exact replay the guard exists to close. Pruning runs on a time throttle rather than a size watermark, and only correctly signed, not-yet-seen handshakes ever reach the map, so it tracks StandIn's real call rate rather than attacker traffic. ## Handling the secret * Keep it in the environment, or in your platform's secret store. * `STANDIN_SECRET` is what `CallServer` and `OutboundCaller` read. The chat lane reads `STANDIN_CHAT_SECRET` **first** and falls back to `STANDIN_SECRET`, so a deployment issued a separate chat credential can run the two lanes on different keys without touching either constructor. With one key for both, leave `STANDIN_CHAT_SECRET` unset. Every variable the SDK reads is listed on [Configuration](/typescript-sdk/configuration). * Never commit it, never log it, and never put it in a URL or query string. * Rotate it in the StandIn portal. A rotated secret takes effect on the next handshake, so live calls finish on the old one. * If a config layer can hand you an unresolved reference, coerce a non-string to the empty string rather than calling `String()` on it. `String({})` yields `"[object Object]"`, a non-empty and guessable secret that a naive presence check would accept. ## Who may call the agent The handshake authenticates the **socket**, not the human. It proves the call came from StandIn. `session.start` then names a caller, and whether that caller may be answered is a different question with a different answer: it is your deployment's policy. The decision stays yours. The **matcher** is in the SDK, so every plugin makes it the same way: ```ts theme={null} import { describeInboundRejection, isInboundCallAllowed, type CallSession, } from "@komaa/standin-sdk"; async onStart(session: CallSession) { const from = session.start.caller.aadId; if (!isInboundCallAllowed(config.inboundPolicy, config.allowFrom, from)) { logger.warn(describeInboundRejection(config.inboundPolicy, from)); await session.end("not-allowed"); return; } } ``` | Function | What it does | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `isInboundCallAllowed(policy, allowFrom, from)` | `"open"` admits everyone; `"allowlist"` and `"pairing"` consult the list; anything else, including `undefined`, refuses. | | `isAllowlistedCaller(from, allowFrom)` | True on an exact caller id match, case-insensitive, **or** a digits-only phone number match. | | `normalizePhoneNumber(input)` | Digits only. | | `describeInboundRejection(policy, from)` | The log line to print, naming the policy and the fix. | Three things are worth carrying from the code: **An unset or unknown policy refuses.** Defaulting the other way would mean a configuration typo silently opens the agent to anyone who can reach the number. **The id branch is what makes a Microsoft Teams caller allowlistable at all.** Their id is an AAD object id, not a phone number, and phone normalization reduces it to the empty string, which matches nothing. That is why the matcher tries both rather than normalizing everything. **`caller.aadId` is empty for guest and anonymous callers**, so an anonymous caller can never match an allowlist. That is the intended outcome, not a gap. This module is TypeScript only, and it is a real difference between the SDKs rather than a documentation gap. The Python SDK ships no policy module: a Python worker writes the same test in its own handler, and the Python call-handler page shows it that way. ## The outbound fetch guard An agent that can show the caller a picture will sooner or later be handed a URL by its own model, and that model is steered by whoever is on the call. So the URL is untrusted input wearing a trusted costume, and a plain `GET` of it is a server-side request forgery: `169.254.169.254` is cloud credentials, `127.0.0.1` is whatever else you run, and `10.0.0.0/8` is the rest of your network. ```ts theme={null} import { assertPublicHttpUrl, fetchPublicImage, isForbiddenIp } from "@komaa/standin-sdk"; ``` | Name | What it does | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `await assertPublicHttpUrl(url)` | Resolves to a `URL`, or throws naming the problem: a scheme that is not http or https, embedded credentials, no host, or a host that resolves into forbidden space. | | `isForbiddenIp(address)` | The address test on its own, for a check of your own. Private, loopback, link-local, carrier-grade NAT, multicast and reserved ranges, and an IPv4 address wearing an IPv6 spelling is judged as the address it embeds. | | `await fetchPublicImage(url, maxBytes)` | The whole guarded fetch, which is what the vision tools use. Bounded on total time, declared length, streamed length and redirects. | The subtle half is the rebind. Validating a hostname resolves it once, and the resolution the HTTP client does a moment later can answer differently, so the address the socket **actually** connects to is re-checked against the same rules. One redirect hop is followed, because image CDNs habitually redirect to the real asset, and the target goes through the whole guard again rather than being trusted for having come from a host that passed. Link-local is on the list by name, because that is where cloud metadata lives. These three are top-level exports here. In the Python SDK they live in `standin.fetch` rather than on the barrel. See [Vision and the avatar](/typescript-sdk/vision#fetching-an-image-a-model-chose) for the image case. ## The generated wire builders The frame builders and parsers are public, because the wire is public, and they are what the shared conformance vectors assert across both languages: `audioFrame`, `sessionEnd`, `pong`, `assistantCancel`, `parseMessage`, `parseSessionStart` and `decodePcm`. All of them are on the barrel here; in the Python SDK only `assistant_cancel` is, and the other six are `from standin.protocol import ...`. `contextSentences` sits with them on the barrel and is the one that has no Python symbol at all: the same three sentences exist there, word for word, but they are built inside the Python call server rather than exposed as a helper. So a test that asserts on the exact wording can import it here and has to retype it there. A handler should still not reach for them. `CallServer` owns the sequence number and the outbound timeline, and a hand-built frame is how those two stop agreeing. ## Transport and limits Terminate TLS in front of the worker. The signature proves origin and freshness, it does not encrypt anything, and the audio on the wire is unencrypted PCM inside the WebSocket frame. A single inbound message is bounded at 2 MB on both the call and chat lanes, and a call-outcome POST is bounded at 8 KB. A malformed URL, a bad percent escape, or an unparseable frame rejects that one request and never escapes to kill the process. ## Next Admission order, the replay guard in place, and the watchdogs. Every `STANDIN_` variable, including both secrets. The outbound dial, and the cross-tenant guard on every reply. The outbound allowlist, which is stricter than any inbound one. # Sending a picture Source: https://docs.komaa.com/typescript-sdk/sending-pictures Attach a checked image to a chat reply, and turn a MEDIA: marker into bytes worth sending, in the StandIn TypeScript SDK. A picture your agent sends travels as **bytes**, never as a link. The reason is worth stating once, because every shortcut here leads back to it: a link in a chat message is a beacon. An off-domain image loads with no click, under your bot's name, it reports back who opened the conversation and when, and whatever it serves can be swapped after everyone has looked at it. Bytes cannot do any of that, and bytes can be checked before they leave. So there is exactly one way to build a picture this SDK will send, and that one way does the checking. This page is about pictures your agent **sends**. Putting a picture on the bot's video tile during a call is a different lane with different limits: see [Vision and the avatar](/typescript-sdk/vision). ## `outboundImage` ```ts theme={null} import { outboundImage } from "@komaa/standin-sdk"; const picture = outboundImage(pngBytes, "image/png", "q3-revenue.png"); ``` It takes a `Buffer` or an already-base64 `string`, a declared content type, and an optional filename. It returns an `OutboundImage`, which is the only shape the chat lane will carry: ```ts theme={null} interface OutboundImage { readonly contentType: string; readonly contentBase64: string; readonly name?: string; } ``` It throws rather than returning something half-checked, and it runs its checks in this order: 1. **The declared type is on the allowlist.** `image/jpg` is folded to `image/jpeg` first, because that spelling is common and is not a media type. Anything else throws naming the four that work. 2. **The bytes are within the cap.** Checked on the decoded length, so a base64 string that expands past the limit is refused. 3. **The bytes carry the signature of the type they claim.** A declared type is a claim made by whatever produced the bytes. The signature is what they are. Without this check an HTML document or an SVG labelled `image/png` is posted into somebody's chat under your bot's name. 4. **The filename is sanitized**, which never throws: a name that cannot be made safe is simply dropped. Step 3 is the one that matters, and it is strict in both directions: the sniffed type must **equal** the declared type. A GIF labelled `image/png` is refused, not quietly relabelled. If you do not know what you are holding, sniff first and pass the answer back in, which is exactly what `loadMedia` does below. That check is also the only thing standing behind a base64 `string`, because `Buffer.from(data, "base64")` is lenient: Node discards characters that are not base64 rather than objecting, so a truncated or scrambled string decodes quietly to fewer bytes and arrives at step 3 as the wrong bytes. The Python SDK decodes strictly and raises on that same input, so do not port a test that expects the decode itself to do the rejecting. Every refusal on this page, from `outboundImage` and `loadMedia` alike, is a plain `Error` rather than a `StandInError`, so an `instanceof StandInError` filter will not catch one. Catch `Error` and read `err.message`: the messages are written to be handed straight back to whatever chose the bytes. The Python twin raises `ValueError` on the same paths. ## What may be sent | | Chat reply | On the call tile | | -------- | ---------------------------------------------------- | ---------------------------------- | | Constant | `OUTBOUND_IMAGE_CONTENT_TYPES` | `DISPLAY_IMAGE_MIME_TYPES` | | Types | `image/png`, `image/jpeg`, `image/gif`, `image/webp` | `image/jpeg`, `image/png` | | Cap | `OUTBOUND_IMAGE_MAX_BYTES`, `1024 * 1024` bytes | `MAX_IMAGE_BYTES`, `1400000` bytes | Every constant in that table is importable from the barrel, so a check of your own can use the same number the SDK does rather than a copy that drifts: ```ts theme={null} import { OUTBOUND_IMAGE_CONTENT_TYPES, OUTBOUND_IMAGE_MAX_BYTES, } from "@komaa/standin-sdk"; ``` `image/svg+xml` is absent from the chat list deliberately, and it is not a gap to fill later. SVG is scriptable XML, which is precisely what "an image" must not be when it renders inside somebody else's client under your bot's identity. ## `sniffImageType` ```ts theme={null} import { sniffImageType } from "@komaa/standin-sdk"; const actual = sniffImageType(bytes); // string | undefined: "image/png", "image/jpeg", "image/gif" or "image/webp" ``` It reads the leading bytes and returns what they actually are, or `undefined` when they are none of the four. WebP gets a second look: a `RIFF` header is only a WebP when bytes 8 to 11 say `WEBP`, and a RIFF container that holds audio would otherwise sail through on its first four bytes. Use it whenever the type you have came from somewhere you do not control: a `Content-Type` header, a file extension, or a field a model filled in. All three are claims. ## `sanitizeImageName` ```ts theme={null} import { sanitizeImageName } from "@komaa/standin-sdk"; sanitizeImageName("../../etc/passwd"); // "passwd" sanitizeImageName("report:final?.png"); // "reportfinal.png" sanitizeImageName(".."); // undefined ``` A filename reaches a chat as a **download**, and the model that chose it is being steered by whoever is in the conversation. So the name is reduced to one path-free segment: backslashes are folded to forward slashes and only the last segment survives, every character below a space is dropped along with `<>:"|?*`, and what is left is trimmed. An empty result, `.` or `..` returns `undefined`, which means the picture is sent with no name rather than with a dangerous one. Names longer than 200 characters are truncated, keeping the extension so the file still opens with the right application. `outboundImage` calls this for you, and it returns `undefined` rather than throwing, because a bad filename is never a reason to drop an answer. ## Getting the picture into a reply Two paths, and they differ in how many messages the person sees. **One message carrying both.** `buildReply` takes the image as its fourth argument, after the kind: ```ts theme={null} import { buildReply, outboundImage, parseInbound } from "@komaa/standin-sdk"; const message = parseInbound(rawBody); const picture = outboundImage(pngBytes, "image/png", "q3-revenue.png"); const reply = buildReply(message, "Here is Q3.", "message", picture); ``` This is the path when you relay messages yourself rather than through `ChatChannel`. A `typing` reply carries neither text nor image: it is a state, not a message. **A second message from inside a handler.** `ChatChannel`'s own reply path posts the string your `respond` returns, and that string carries no picture. `chat.send()` is how you post one: ```ts theme={null} import { ChatChannel, outboundImage } from "@komaa/standin-sdk"; const chat: ChatChannel = new ChatChannel({ respond: async (message) => { const { words, chart } = await agent.ask(message.text); if (chart !== undefined) { await chat.send({ tenantId: message.tenantId, conversationId: message.conversationId, text: "Here is the chart.", image: outboundImage(chart, "image/png", "chart.png"), bindingId: message.bindingId, idempotencyKey: `${message.activityId}:chart`, }); } return words; }, }); ``` Your handler must return something more than whitespace. The channel reads a blank answer as a failed turn and posts an error line, so you cannot post the picture yourself and then return `""` to suppress the text. Because `send` runs inside your handler, the picture lands **before** the words: give the picture its caption, as above, and return the words that follow it. If you need the caption and the picture in one message, build the reply yourself with `buildReply`. `send` is best-effort and returns `false` rather than throwing, since a failed post must never break a live call. Pass an `idempotencyKey` whenever a retry is possible. `bindingId` says which StandIn connection the message is from, and one organisation can have several, so echo the inbound value rather than leaving it out. ## `MEDIA:` markers Some agent frameworks let a reply name a file by writing a line like `MEDIA:/tmp/chart.png` and expect the channel to attach it. That marker is an **instruction to the channel**, not something anyone should see. A channel that does not understand the convention posts the line as prose, and the person reads a temporary file path in their chat. On a call it is worse: text to speech reads the path out, character by character. `parseMedia` takes the markers out and hands back what they referred to. `loadMedia` turns one reference into bytes, through the same guards everything else in this SDK uses. ### `parseMedia` ```ts theme={null} import { parseMedia } from "@komaa/standin-sdk"; const { text, refs } = parseMedia(await agent.ask(question)); // text: "Here is the chart." // refs: ["/tmp/chart.png"] ``` The returned `text` is what to post **and** what to say. Both, always: the whole point is that nobody sees or hears the marker. `refs` holds what the markers pointed at, in the order they were written. The shape is exported as `AgentMedia`. Four things the parser does on purpose: * **It is case-insensitive and one marker per line.** `MEDIA:`, `media:` and `Media: ` all match. * **It is anchored to the end of the line**, so a path with spaces survives intact. The documented form is backticked for exactly that reason, and the backticks are stripped: `` MEDIA:`/tmp/a b.png` `` yields `/tmp/a b.png`. Splitting on whitespace would truncate that path at the first space. * **A line that is not plausibly a reference is left alone.** A reference has to start with `http://`, `https://`, `/`, `./`, `../`, `~/` or a Windows drive letter, or end in `.png`, `.jpg`, `.jpeg`, `.gif` or `.webp`. Without that narrowing, a sentence like "MEDIA: we should talk to them about it" vanishes out of the answer and whoever wrote it never learns why. * **The blank line a removed marker leaves is collapsed.** Three or more newlines become two, which is a paragraph break. Two are left alone, because they already are one. Because the marker runs to the end of its line, nothing may follow the path on that line. Write `MEDIA:/tmp/chart.png` alone; `MEDIA:/tmp/chart.png and here it is` makes the whole trailing sentence part of the reference, and then `loadMedia` fails on a file that does not exist. ### `loadMedia` ```ts theme={null} import { loadMedia, parseMedia } from "@komaa/standin-sdk"; const { text, refs } = parseMedia(reply); for (const ref of refs) { try { const picture = await loadMedia(ref, { roots: ["/srv/agent-output"] }); // picture is an OutboundImage, ready for buildReply or chat.send } catch (err) { // Worth logging and worth saying: the agent chose this reference, and the // reason is a sentence it can act on next turn. console.warn(`could not attach ${ref}: ${String(err)}`); } } ``` Catch per reference, as above. One reference that will not load should cost that picture, never the whole answer: `text` is still worth posting, and the reason is a sentence the agent can be told. The SDK's own `logger` is not exported, and there is no `@komaa/standin-sdk/log` subpath to reach it through, so a sample like this uses `console` or your own logger. What the barrel does export is `setLogger`, which replaces the one the SDK writes its own lines with: pass an object with `debug`, `info`, `warn` and `error` and the SDK's lines land wherever the rest of your worker's do. The Python twin takes the other route and exposes `standin.log.logger` directly. It resolves one reference and returns an `OutboundImage`. It throws with something worth reading, because the caller is on a path where the alternative is a dropped answer with no explanation. Options are `LoadMediaOptions`: | Option | Default | Meaning | | ---------- | ------------------------------- | --------------------------------------------------------- | | `roots` | `STANDIN_MEDIA_ROOTS` | Directories a local reference may be read from. | | `maxBytes` | `OUTBOUND_IMAGE_MAX_BYTES` | The size ceiling, applied to a fetch and to a file alike. | | `name` | the basename of the path or URL | The filename the picture is sent with. | What it does, and why each branch exists: * **`http://` or `https://`** goes through the SDK's own fetch guard with a ten second budget, so a reference aimed at a private, loopback, link-local or reserved address is refused rather than fetched. The address the socket actually connects to is re-checked, which closes the window where a hostname resolves publicly for the validation and privately for the fetch. See [Fetching an image a model chose](/typescript-sdk/vision#fetching-an-image-a-model-chose). * **Any other scheme is refused by name**, including `file://` and `data:`. Handing a `file://` URL to a URL fetcher is the usual way around a path guard, so it is rejected before anything opens it. * **Everything else is a local path**, read only from a directory an operator named, and only after a `stat`: the size is checked **before** the read, because the point of a cap is not to load the file. Whatever the branch, the bytes are sniffed at the end and the sniffed type is what gets sent. A local file declares no type at all, so the sniff simply decides. A fetch does declare one, and a declared type that disagrees with the bytes throws instead of being relabelled, with only `application/octet-stream` accepted as "no opinion". A type from a response header is a claim; the bytes are the fact. A response with no `content-type` header at all is read as `image/jpeg`, so a PNG served bare is refused with `that was served as image/jpeg but the bytes are image/png`. That is the fetcher's default, not a sniff, and it is the one refusal on this list that usually means the server is wrong rather than the picture. Fix the header, or fetch the bytes yourself and hand them to `outboundImage`. | What you passed | What comes back | | ------------------------------------------ | ------------------------------------------------------------------------------- | | `""` or whitespace | `there was nothing to send` | | A local path with no roots configured | `sending a local file is off until a directory is named in STANDIN_MEDIA_ROOTS` | | A path outside the roots | `that file is outside the directories this worker may read` | | `file://`, `data:`, `s3://` | ` references are not allowed here` | | A missing file | `no such file` | | A file over the cap | `that file is N bytes, over the M limit` | | A text file, or an SVG | `that file is not a picture this can send` | | A URL served as one type, bytes of another | `that was served as X but the bytes are Y` | ### `mediaRoots` and `STANDIN_MEDIA_ROOTS` ```ts theme={null} import { MEDIA_ROOTS_ENV, mediaRoots } from "@komaa/standin-sdk"; MEDIA_ROOTS_ENV; // "STANDIN_MEDIA_ROOTS" mediaRoots(); // [] until an operator sets it mediaRoots(["/srv/charts"]); // or pass them, bypassing the environment ``` `mediaRoots` returns the directories a local reference may be read from, resolved through symlinks. With no argument it reads `STANDIN_MEDIA_ROOTS`, split on the platform path delimiter: `:` on Linux and macOS, `;` on Windows. `~/` is expanded. A root that cannot be resolved, because nothing is there, is dropped, since a directory that does not exist cannot contain anything. **It is empty by default, and that is the whole design.** Sending a local file is off until somebody opts in. An agent can be talked into writing `MEDIA:/etc/passwd`, and the answer to that has to be a refusal rather than a file read followed by an upload into somebody's chat. Two details in the containment check earn their keep: * **Both sides are resolved through symlinks first.** A symlink inside a root that lands outside it is judged by where it lands, so dropping a link named `innocent.png` into an allowed directory buys nothing. * **The comparison is separator-terminated.** Without that, `/tmp/rootevil` passes for `/tmp/root`, and one adjacent directory becomes readable to a path a model chose. Name the narrowest directory that works, and make it one your agent writes into rather than one it merely happens to be able to read: ```bash theme={null} export STANDIN_MEDIA_ROOTS=/srv/agent-output ``` Pass `roots` explicitly instead when a single worker serves more than one purpose. An explicit list wins over the environment entirely, so the variable is not consulted at all on that call. It is still resolved the same way: a root you pass that does not exist is dropped exactly as one from the environment would be, so an explicit list is not a way around the check. ## Next The lane a picture travels on, and what it echoes back. Every variable the SDK reads, including the roots above. # Vision and the avatar Source: https://docs.komaa.com/typescript-sdk/vision What the caller shows your agent, what your agent shows back, and the face the caller sees, in the StandIn TypeScript SDK. A Microsoft Teams call carries more than voice. StandIn samples the caller's camera and their screen share and forwards single frames to your worker, and it will draw an image you send onto the bot's own tile. Both halves are optional. A plugin that only wants to talk never touches any of this. ## Frames arrive sparsely StandIn drops a frame rather than queueing it when the socket is busy. This is **not** a video stream and must not be treated as one. The useful shape, and the one every provider plugin ends up with, is to keep the latest frame and look only when something asks: ```ts theme={null} const frame = session.latestVideoFrame(); // screen share wins over camera if (frame !== undefined) { const answer = await describer.describe(frame, "What is on the slide?"); } ``` The server keeps the latest frame per source for you, so a plugin that only wants on-demand vision implements no callback at all. Pass `"camera"` or `"screenshare"` to be explicit; with no argument the screen share wins, because an agent asked to look is nearly always being asked about what is being *shown* rather than who is showing it. Take every frame instead, which is what [ambient vision](#ambient-vision) needs, by implementing the optional sixth method: ```ts theme={null} class MyHandler { async onVideoFrame(frame: VideoFrame) { // ... } } ``` `onVideoFrame` runs on the receive path of a live call. Do a slow model call off the frame loop, exactly as you would in `onCallerAudio`. Every method on `CallHandler` is optional in TypeScript, so `onVideoFrame` is simply one more of them. The Python twin keeps it on a separate protocol, because its `CallHandler` is runtime-checkable and a sixth member there would break `isinstance` for every handler written before the vision lane existed. ## What a frame is | Attribute | What it is | | ---------------------------------- | ------------------------------------------------------------------------------------ | | `source` | `"camera"` or `"screenshare"`. | | `width`, `height` | Pixels, already downscaled by StandIn. | | `mime` | `image/jpeg`. | | `dataBase64` | The image exactly as it arrived. | | `data` | The decoded bytes, for an API that uploads a file. | | `dataUrl` | A `data:` URL, which is what most vision models take. | | `participantId`, `participantName` | Who it belongs to. Best-effort, and **absent** for guest and anonymous participants. | An unusable frame is dropped rather than raised. The call is healthy, the caller is still talking, and one malformed image is not worth ending a conversation over. `dataBase64` is the field kept verbatim and the other two are derived from it, because base64 is the form most providers want back. That also makes the drop check exact: `parseVideoFrame` re-encodes what it decoded and compares, since `Buffer.from(value, "base64")` never throws on bad input and instead silently discards what it cannot read. ### Who it came from, and whether it changed `VIDEO_SOURCES` is the pair `source` may hold, `["camera", "screenshare"]`, and the `VideoSource` type is derived from it. A message naming anything else never becomes a `VideoFrame` at all, so you never have to defend against a third lane appearing. Four helpers turn a frame into something a model can be told about: | Function | What it gives you | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `frameOwner(frame)` | The participant name the wire carried, or `undefined`. | | `fallbackOwner(source)` | `"a participant"` for a screen share, `"the caller"` for a camera. | | `frameCaption(owner)` | The sentence that goes beside the picture, built from that owner alone: `screen shared by a participant`, or `camera of Dana`. | | `frameDigest(dataBase64)` | A 32 character fingerprint of the frame. | Attribution **degrades rather than vanishing**. Guest and anonymous participants arrive with no name, and `fallbackOwner` gives those a generic label instead of nothing, because "a participant's screen" is worth more to a model than an unlabelled picture. Dropping the attribution instead would leave a model in a group call unable to say whose deck it is looking at. `FrameDescriber` composes the two for you, and so does ambient vision, both of them as `frameOwner(frame) ?? fallbackOwner(frame.source)`. The digest answers one question, "is this the same screen as last time?", without keeping the picture to compare against. It hashes the base64 rather than the decoded bytes: two encodes of an unchanged screen are byte-identical, so it is exact, and it costs no decode on a path that sees every frame. The keyframe store uses it to avoid filling itself with one frozen slide, and ambient vision latches on it. ## Giving a voice model eyes Most speech-to-speech providers hear but cannot see. `FrameDescriber` closes that gap: the frame goes to a vision model of your choosing and a sentence comes back. ```bash theme={null} export STANDIN_VISION_API_URL=https://api.openai.com/v1/chat/completions export STANDIN_VISION_MODEL=gpt-4o-mini export STANDIN_VISION_API_KEY=... ``` ```ts theme={null} import { FrameDescriber } from "@komaa/standin-sdk"; const describer = FrameDescriber.fromEnv(); // undefined when not configured ``` Any OpenAI-compatible chat-completions endpoint that accepts images works, including one you run yourself. The frame is sent for inference and **not stored**, which is the difference between this and uploading it into a provider's own conversation history. `describe` throws on anything that goes wrong, so a caller can hand the reason back to the agent that asked, and it puts `frameCaption` into the question it sends, so the model is told whose screen it is looking at before it is asked anything about it. This URL is deliberately **not** put through the SDK's fetch guard. It is yours, set by you in the environment, and a vision model on localhost is a normal way to run one. That is the opposite of an image URL a model chose, which is untrusted and does go through the guard. ## Showing the caller something ```ts theme={null} await session.displayImage(chartPng, { mime: "image/png", durationMs: 4000, caption: "Q3 revenue", }); ``` The image is drawn on the bot's video tile for a few seconds, then the avatar returns. `mode` is `"fullscreen"` or `"overlay"` for a picture-in-picture inset. A Buffer or an already-base64 string both work. An image that is too large, or a type the service will not draw, is refused here where the error names the problem, rather than letting the service close the socket in the middle of a call. `DISPLAY_IMAGE_MIME_TYPES` is the whole list of what StandIn will draw, `["image/jpeg", "image/png"]`, and `MAX_IMAGE_BYTES` is `1400000`. That ceiling is not arbitrary: one wire message is bounded at 2 MB by both SDKs and base64 costs a third on top of the raw bytes, so 1.4 MB of image is what actually fits inside the envelope. A string you pass is measured **decoded**, because the decoded size is what the envelope bounds. Both names are at the barrel here; on the Python side they live in `standin.vision`. `MAX_IMAGE_BYTES` is the **outbound** ceiling, on what you draw on the tile. Inbound chat attachments have a separate and larger one, and passing this constant where that limit belongs compiles cleanly and quietly caps them at 1.4 MB. See [Chat attachments](/typescript-sdk/chat-attachments#images). ### Fetching an image a model chose An agent that can show a picture will sooner or later be handed a URL by its own model, and that model is steered by whoever is on the call: ```ts theme={null} import { fetchPublicImage, MAX_IMAGE_BYTES } from "@komaa/standin-sdk"; const { bytes, mime } = await fetchPublicImage(url, MAX_IMAGE_BYTES); ``` http and https only, no embedded credentials, and no host that resolves into private, loopback, link-local or reserved space. The address the socket actually connects to is re-checked, closing the window where a hostname resolves publicly for the validation and privately for the fetch. One redirect hop is followed, because image CDNs habitually redirect to the real asset, and the target goes through the whole guard again. ## The tool surface `VisionTools` is the layer a model reaches for, and it lives in the core because every provider wants the same things and none of them should write the guards again. Build one per call and route your provider's tools at it. | Method | What it does | | ------------- | ----------------------------------------------------------------- | | `look` | Answer a question about what the caller is showing now. | | `lookBack` | Answer about a frame the caller has already moved past. | | `show` | Put an image you already have on the bot's tile. | | `showUrl` | Fetch an image the model chose, through the guard, and show it. | | `showMany` | Show several pictures in turn, paced, without blocking the model. | | `walkthrough` | Say and show several of those in order, pausing for each. | **None of them raise at a model.** Every one returns a sentence, because the caller is a tool result being read back to something that will say it out loud, and an exception there is a silent tool and a confused agent. `VisionToolsOptions` is the whole constructor surface: `describer`, `budget`, `keyframes` and `defaultDisplayMode`. Pass none and you get a `VisionBudget` and a `KeyframeStore` of its own and no describer, and `look` says so in a sentence rather than failing. `reset()` stops anything running and forgets what was shown, which is what teardown calls. Two guards travel with them. **A budget**, because a model that can look can look in a loop and each look is a paid inference over somebody's screen. It is a sliding window, so a long call is not punished for having been long, and a failed look is refunded rather than charged. Spending returns a token and refunding takes that token back: two tool calls overlap, and refunding "the most recent charge" would refund the wrong one. `new VisionBudget(6)` is the default ceiling per rolling minute. `tryConsume()` takes one look's worth or returns `undefined`, `refund(token)` gives one back and is idempotent, and `spent` is what has been charged in the current window. A `maxPerMinute` of zero means no cap at all, which is a deliberate choice rather than a default. **A keyframe store**, so "what did that slide say?" can be answered about a slide that is already gone. It is bounded, and it only keeps anything **while the call is being recorded**: keeping a history of somebody's screen is a materially different promise from glancing at it once, and the recording is what told the caller their call is being kept. `new KeyframeStore(16)` is the default capacity. `offer(frame, recording)` returns whether the frame was kept, `recent(source?)` reads the history oldest first, and `size` and `clear()` are the rest of it. It latches per source on `frameDigest`, so a screen nobody touched is kept once instead of filling the whole store with one picture. ### Fullscreen or beside your face Every showing method takes a display mode, and the model chooses it per picture: `fullscreen` for something being read, `overlay` to keep the avatar beside it. `show_image` carries the choice as an enum in its schema, which a model obeys far more reliably than the same list written into a description. Anything else a model says, and `pip` and `full` are both things it will say, falls back to the default you configured. When you configured none, the field is left off the wire entirely and the service decides, rather than this SDK deciding for it. ```ts theme={null} const tools = new VisionTools(session, { describer, defaultDisplayMode: "overlay" }); await tools.showUrl(url, "Q3 revenue", "fullscreen"); ``` `DISPLAY_MODES` is that pair, `["fullscreen", "overlay"]`, and `normalizeDisplayMode(value, fallback?)` is the single rule that turns whatever a model said into one of them or into the fallback. It is exported because every plugin has to apply the *same* rule: a provider that mapped `pip` to `overlay` on its own would give one model a placement the other providers refuse, and one prompt would then behave differently on two deployments. What the caller can actually see is on `tools.lastShown`, recorded only after a send resolved. So "email me that" attaches what they saw rather than what was attempted. It is a `ShownImage`, carrying the `image`, its `mime`, the `name` shown beside it and the `atMs` it went up, with `asBase64()` for an API that wants the encoded form. One slot, replaced each time, because a list would be a growing copy of everything shown on the call. The name comes from `displayImageName(pathOrUrl, mime)`. It takes the last path segment with any query and fragment cut off, and only when what is left already looks like a filename: a name, a dot, a short extension, no separators of any kind. Anything else becomes `image.png` or `image.jpg` built from the type. `../../etc/passwd` is not a filename, and this string was chosen by a model and is about to be drawn on the caller's screen. A caption is trimmed to 200 characters before it reaches the screen, for the same reason the name is checked: a model's strings are as long as whoever is steering it wants them to be, and this one is drawn on somebody's call. ### Several pictures in a row ```ts theme={null} await tools.showMany( [ { image: chartPng, mime: "image/png" }, { image: "https://example.com/table.png" }, ], "Q3", "fullscreen", 4000, ); ``` Each entry is a `ShowItem`: an `image` that is bytes, base64 or an https URL, an optional `mime` defaulting to `image/jpeg`, and an optional `name`. In TypeScript that is an interface, so an object literal is the whole construction; the Python twin is a dataclass you name. The first picture is on the tile before the call resolves, so the model can say "here it is" and be right. The rest are paced from a detached chain. A model that waited out a ten-picture slideshow before speaking would leave the caller in silence for most of a minute. `MAX_SLIDESHOW_IMAGES` pictures at a time, which is ten, and the sentence that comes back says when there were more: a model asked for "the slides" can mean forty of them. `holdMs` defaults to `SLIDESHOW_HOLD_MS`, four seconds, and whatever you or a model passes is clamped to between one and thirty seconds. Each non-final picture is held past the pacing gap, by `SLIDESHOW_OVERLAP_MS` (half a second, at the barrel here and in `standin.vision_tools` on the Python side), so the next one arrives before the last has expired and the tile never blanks between two of them. The last carries no duration at all, so what stays on screen is the service's own default. Each picture is also loaded inside the pacing loop rather than up front, so a slideshow of ten URLs does not fetch all ten before the first appears, and one that fails skips that picture rather than the rest. There is one tile, so starting a slideshow or a walkthrough stops whatever was already running on it. The one being replaced is woken rather than killed: a picture already being sent finishes being sent, and the new first picture goes up immediately instead of waiting out the old gap. `tools.slideshow` is the promise for the one now running, when you want to let it finish. ### Saying each one as it goes up `walkthrough` is the narrated version, and a `WalkthroughStep` is one beat of it: a line to `say`, and optionally an `image` with its `mime` and `caption` to put up once that line has been said. ```ts theme={null} import type { WalkthroughStep } from "@komaa/standin-sdk"; const steps: WalkthroughStep[] = [ { say: "First, last quarter.", image: q3Png, mime: "image/png" }, { say: "And here is the forecast.", image: forecastPng, mime: "image/png" }, ]; await tools.walkthrough(steps, (text) => this.say(text), () => this.callerIsTalking); ``` `speak` is yours, a `Speaker` of `(text: string) => Promise`, because "the line has finished being said" is a thing only your provider knows, and a walkthrough that guesses talks over itself. `interrupted` is checked between beats, for the same reason: somebody who cuts in has stopped the tour, and only the plugin can tell that they did. A step whose line or picture fails stops the tour there and names the step, rather than narrating pictures nobody is seeing. ### Showing a web page There is no browser in this SDK and there will not be one. `showPage` is the seam: you supply the renderer, it supplies the guard. ```ts theme={null} import { CallTools, SHOW_PAGE_TOOL, VisionTools } from "@komaa/standin-sdk"; const vision = new VisionTools(session, { describer }); const tools = new CallTools(session, { vision }); tools.register(SHOW_PAGE_TOOL, (p) => vision.showPage(String(p.url), undefined, myRenderer)); ``` `register` is on `CallTools`, not on `VisionTools`: one model sees one list of tools, so a tool of your own is added where the built-ins already are. The handler itself reaches straight into the vision tools. Your renderer is a `PageRenderer`: it takes a URL and returns bytes plus a mime type, never a path. A path would make this SDK read a file chosen downstream of whoever is on the call, which is not a thing it should be able to do. The URL goes through the same public-address guard as every other URL a model chose, and it goes through it **even when your renderer is a browser with private-network protection of its own**. That protection assumes whoever wrote the URL already has a shell on the machine. Here the URL was written by a model a stranger is steering, which is precisely the case the relaxation lets through. `PAGE_RENDER_TIMEOUT_MS` is forty-five seconds and is `showPage`'s fourth argument when you want a different one. A renderer past it is not cancelled, because nothing here can cancel a host browser; the timeout is what stops the caller waiting in silence, and what comes back counts whole seconds, never `1 seconds` and never `0 seconds`, because it is read out loud to the person who is waiting. `PAGE_DISPLAY_MS` is fifteen seconds, longer than a chart gets, because a page is read rather than glanced at, and with no caption of your own the URL is used, cut to 80 characters. The Python twin spells that first one `PAGE_RENDER_TIMEOUT_S` and holds `45.0`. The same forty-five seconds, a different units suffix, and a value that cannot be copied between the two SDKs. `showPage` is deliberately not one of the built-in tools. The built-in list is declared unconditionally, so putting it there would tell every model on every deployment that it can show a web page, and then apologise to the caller on every call where no renderer exists. Register it when you have one. Putting a **local document** on the tile is Python only today: `show_file` and its `STANDIN_SHOW_ROOTS` allowlist have no TypeScript twin. These tools cover looking, showing an image you have, showing one from a URL, and showing a page you render. ## Ambient vision `look` answers when a model reaches for it, which is the right shape most of the time and has one blind spot: the model has to know there is something to look at. Somebody who shares a deck and says "what do you think?" has told it nothing it can act on. `AmbientVision` closes that by pushing what changed on screen into the conversation between turns. It is **off unless a plugin turns it on**, because it spends money on every scene change and not every deployment wants that. ```ts theme={null} import { AmbientVision, FrameDescriber, VisionTools, ambientImageDataUrl, type AmbientImage, type CallSession, type VideoFrame, } from "@komaa/standin-sdk"; class MyHandler { #tools!: VisionTools; #ambient!: AmbientVision; async onStart(session: CallSession) { this.#tools = new VisionTools(session, { describer: FrameDescriber.fromEnv() }); this.#ambient = new AmbientVision(session, (image) => this.#push(image), { enabled: true, budget: this.#tools.budget, }); } async onVideoFrame(frame: VideoFrame) { this.#ambient.offer(frame); } async aclose(reason: string) { await this.#ambient.close(); } async #push(image: AmbientImage) { // Into the provider's context, without asking it to answer. await this.provider.addImage(ambientImageDataUrl(image), image.caption); } } ``` `offer` is synchronous, never blocks and never throws: it runs on the receive path of a live call, so all it does is keep the frame and wake the pass that does the work. That pass is *scheduled* rather than started inline, because calling an async function runs its body up to the first `await` synchronously, and a pass begun inside `offer` would do vision work on the frame loop and deliver frames out of order. `flush()` asks for a pass now and is safe to call from anywhere. `close()` stops it permanently and forgets what it was holding; the Python twin spells that one `aclose()`. The session you hand it only has to satisfy `AmbientSession`, which is `recordingActive` and nothing else, so a fake in a test is one field. The options are `AmbientVisionOptions`. Neither type has a Python counterpart to import: there the options are keyword arguments, and the session is read for `recording_active` rather than typed at all. ### The sink is yours `AmbientSink` is the one thing you supply, `(image: AmbientImage) => Promise`. What to DO with a picture stays in the plugin, because only the plugin knows how to hand one to its provider. Two rules on it, and both are load bearing. **It must not make the agent reply.** Ambient vision is context. An agent that answers every scene change talks over the person presenting, which is worse than not having looked. **It must reject when delivery fails.** A sink that swallows its own error latches a frame that never arrived, and the model never sees that screen again. `AmbientImage` is the frame with the decisions already made: `source`, `mime`, `dataBase64`, `width`, `height` and `ts` as they arrived, plus the `owner` and the `caption` that says whose screen this is. `ambientImageDataUrl(image)` builds the `data:` form for the providers that take one. It is a free function here because `AmbientImage` is an interface that a plain object satisfies; the Python twin is a dataclass and carries the same thing as an `image.data_url` property. ### What keeps it from being expensive or creepy **The recording gate is checked before a frame is even stored.** Streaming somebody's screen to a model is a materially different promise from glancing at it once, and the recording is what told them their call is being kept. Storing the frame and gating only the send would mean that turning the recording on surfaces something from before the caller was told. Set `requireRecording: false` only where something else made that promise. **The change latch is the last frame actually delivered**, per source, not the last one seen. A screen nobody touched costs nothing, and a delivery that failed leaves the latch alone and refunds its charge, so the same screen is retried rather than skipped. `changeKey` replaces how sameness is judged; by default it is `frameDigest`. **The reserve stops the ambient lane spending the budget the caller's own request needs.** `VisionBudget.reserve` is a quarter of the window and never less than two, and the ambient lane is refused once the window is down to it, while an explicit `look` can still spend. A refused ambient push stops the whole pass rather than trying the next source, which would only spend the same exhausted budget. Hand `AmbientVision` the same `VisionBudget` your `VisionTools` holds, as the example above does, or that reserve protects nothing: given no budget it builds its own at `DEFAULT_AMBIENT_MAX_PER_MINUTE`, thirty a minute, and the two lanes are then capped independently instead of sharing one window. Handing it a budget with no ceiling at all, while enabled, logs a warning: every scene change is then charged. ### Pacing, and holding what cannot go yet `AMBIENT_SOURCE_ORDER` is `["screenshare", "camera"]`. When both changed, the screen share goes first: somebody presenting is nearly always talking about the screen rather than about their face. `AMBIENT_BACKSTOP_MS`, six seconds, is how often it looks again when no frame arrived at all. A screen share can go quiet without ending, and the last thing on it is still what is being discussed. The timer is armed by the first frame that gets past the gate, so a call with no video never starts one, and `backstopMs` of your own is floored at half a second. `sinkReady` covers a provider whose socket comes up after the call does. While it answers no, images are charged, latched and held rather than dropped, because they are going to be sent and refunding would re-send the same screen once the sink came up. What was held goes out oldest first once it answers yes, so the model sees the screen change in the order it happened. The queue is bounded at `MAX_QUEUED_AMBIENT_IMAGES`, six: a sink that never comes up would otherwise hold the whole call's video, and the oldest is what goes, because what is on screen now is worth more than what was. A held image that fails on its way out is dropped rather than requeued, or a dead sink grows the queue for ever. `onDelivered` is called with each image that actually landed, which is what a meeting recap hooks so the minutes can say what was on screen, and a throw inside it never fails a delivery that already happened. `delivered` and `queued` count those two things for a health check. ## The avatar StandIn renders the avatar tile, and your worker steers it with two additive hints: the emotion the face wears, and the viseme timeline that makes the mouth match the words. Those, and putting your agent's own video on the tile in place of the rendered avatar, are on their own page: [The avatar](/typescript-sdk/avatar). # Turn-taking Source: https://docs.komaa.com/typescript-sdk/voice VoiceLane, utterance segmentation, WAV handling and paced playback for an agent that is not a realtime model, in the StandIn TypeScript SDK. A realtime speech-to-speech provider is handed the caller's audio and hands audio back, and the turn-taking is theirs. Everything else is not like that. A transcription service wants one utterance at a time. A language model wants text. A text-to-speech engine hands back a whole buffer that somebody has to feed out at the rate a call consumes it. That shape is the same whichever three services you pick. A realtime plugin needs none of this and pays nothing for it. `voice.ts` is a module you do not import. ## One utterance per phrase A Microsoft Teams call delivers audio continuously: silence is still frames, fifty a second. `UtteranceSegmenter` is the gate between that and a service that wants a phrase. ```ts theme={null} import { UtteranceSegmenter } from "@komaa/standin-sdk"; const segmenter = new UtteranceSegmenter(); async onCallerAudio(pcm: Buffer) { const utterance = segmenter.feed(pcm); if (utterance !== undefined) await transcribe(utterance); } ``` Five bounds, and each exists because of a specific failure. | Bound | Default | Why | | ---------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `speechRms` | `0.02` | The loudness at which a frame counts as speech rather than room noise, on the 0.0 to 1.0 scale `pcm16Rms` returns. Nothing else opens the gate, so this is the first knob to reach for on a bad line. Raise it on a noisy room, where air conditioning or a fan holds the gate open and every utterance runs to the ceiling. Lower it for a quiet caller on a poor microphone, whose speech never trips it and who is never heard at all. | | `silenceMs` | `800` | A pause inside a sentence is not the end of one. Too short and the agent interrupts a thinking caller; too long and it feels slow. | | `prerollMs` | `240` | The syllable that trips the gate is part of the word. Without it every utterance begins mid-consonant and the transcript loses the first word of most sentences. | | `minUtteranceMs` | `120` | A cough is not a turn. Sending one costs a request and returns nothing worth answering. Judged on the loud part alone, so the pre-roll and the trailing silence cannot carry a click over the floor. | | `maxUtteranceMs` | `30000` | A stuck-open microphone or a television in the room never goes quiet, and without a cap one utterance grows for the whole call. | Every default is an exported constant, so a test can assert against the value rather than retype it: `DEFAULT_SPEECH_RMS`, `DEFAULT_SILENCE_MS`, `DEFAULT_PREROLL_MS`, `DEFAULT_MIN_UTTERANCE_MS`, `DEFAULT_MAX_UTTERANCE_MS`. `flush()` takes what is held mid-utterance, for teardown. `reset()` abandons it, for a barge-in. `speaking` is true while the caller is mid-utterance right now. This gate answers "did the caller stop talking?". It does not answer "was that the caller at all?". On a speakerphone the agent's own voice comes back up the caller leg loudly enough to clear any `speechRms` you would want to set, and the fix is a time window against the playout clock rather than a louder gate. See [Realtime providers](/typescript-sdk/realtime-providers#the-agent-answering-itself), which applies here too whenever the caller is not on a headset. ## The WAV a speech engine hands back It is rarely the one you wanted: 32-bit float, 44.1 kHz, stereo, or wrapped in `WAVE_FORMAT_EXTENSIBLE`, which ffmpeg emits even for plain PCM. Every one of those plays as noise on a call unconverted, and the header does not make the failure obvious. ```ts theme={null} import { decodeWav, encodeWav } from "@komaa/standin-sdk"; const pcm = decodeWav(await tts.speak(text)); // PCM16 mono at the call's rate ``` Chunks are walked rather than assumed at a fixed offset, because a real encoder puts `LIST` and `fact` chunks before the data and a fixed offset reads them as samples. Stereo is averaged rather than halved, so whoever is on the right channel is not lost. Floats outside the range are clamped, because wrapping is the loudest possible click. `encodeWav` goes the other way, for a transcription service that will not take raw PCM. ## Playing it back A text-to-speech engine returns a whole utterance at once. A call takes 20 milliseconds every 20 milliseconds. Sending the buffer in one go hands the service seconds of audio it must queue, and that queue is what makes a barge-in arrive too late to matter: the caller interrupts, the model stops, and the bot keeps talking for the length of what was already sent. ```ts theme={null} import { PacedPlayback } from "@komaa/standin-sdk"; const playback = new PacedPlayback((pcm) => session.sendAudio(pcm)); const result = await playback.say(pcm); if (result.interrupted) { /* ... */ } ``` The pacing is on an **absolute clock**. Sleeping 20 ms per frame accumulates every scheduling delay, and a minute of speech ends seconds behind where it should be. This one sleeps until the next frame is due, so a late frame is followed by a short sleep rather than a full one. The second constructor argument is the frame size in milliseconds, `FRAME_MS` by default, and the byte size follows from it. `playing` is true while a buffer is going out. `cancel()` stops what is playing, and is safe to call from a receive loop. Turns are serialised, so two cannot interleave into one stream the caller hears as both at once. The result carries `sentMs` against `totalMs`, which is the difference between "I told them" and "I started to". ## One object that runs the whole turn The three pieces above are the parts. `VoiceLane` is the assembly: one per call, fed the caller's frames, responsible for everything between a frame arriving and an answer being heard. ```ts theme={null} import { VoiceLane, type CallSession } from "@komaa/standin-sdk"; class MyHandler { #lane!: VoiceLane; async onStart(session: CallSession) { this.#lane = new VoiceLane(session, myStt, myAgent, myTts); } async onCallerAudio(pcm: Buffer) { await this.#lane.feed(pcm); } async aclose(reason: string) { await this.#lane.aclose(); } } ``` `feed` never blocks and never throws. It runs on the receive path of a live call, so the turn it starts is detached: awaiting a model there stops frames arriving, and a lane that has stopped hearing the caller cannot notice an interruption. ### The three callables | You supply | It is given | It returns | | ------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------- | | `transcribe` | one whole utterance, PCM16, 16 kHz, mono `Buffer` | the words, as `Promise`. Empty or whitespace means nothing was said | | `answer` | that transcript | the answer as `Promise`, or an `AsyncIterable` of pieces | | `synthesize` | one piece of answer text | speech as PCM16, 16 kHz, mono: `Promise`, or an `AsyncIterable` of chunks | Each half of that contract carries its weight. `transcribe` is handed a phrase, not frames. Working out where the caller stopped is the segmenter's job above, and it is the one part of this a transcription service will not do for you. Its empty return is a signal rather than a failure. It is how you say "there were no words in that", and the lane acts on it. `answer` returning a plain string is the simple case. Returning an async iterable is what gets the first sentence out before the last one exists. The lane tells the two apart at runtime, so a callable is free to return either without a flag to configure. `synthesize` returns PCM at the call's rate, because what comes back goes straight to paced playback. Nothing in the lane decodes a WAV for you: run `decodeWav` over it first if your engine returns one, and `resamplePcm16` if it speaks at 24 kHz. ### The awkward parts **One turn at a time.** A new utterance retires the turn in flight before it starts its own. An agent asked two questions at once answers neither well, and both answers would be spoken over each other. JavaScript cannot cancel a promise, so the older turn is retired by generation: each turn carries a number, a new utterance bumps it, and the turn checks that number again before it speaks the answer. The retired turn runs to completion, and the answer it comes back with is never spoken. Whatever it had already started playing is dropped as the new turn begins. Nothing cancels your callables, so a request that is already in flight is still paid for, and whatever one of them holds open is released by its own `finally` rather than by the lane. The Python twin cancels the task instead, because asyncio can. **Barge-in lands when the interruption starts, not when it ends.** The moment the caller's voice opens over the top of an answer, the buffered audio is dropped and StandIn is told to drop what it still holds. Waiting for that utterance to finish would spend the whole of it talking at somebody who has stopped listening, which is the difference between a call that feels alive and one that does not. `bargeIn()` is public, for an interruption the caller's audio does not show: ```ts theme={null} async onGoodbye(text: string) { await this.#lane.bargeIn(); await this.#lane.say(text); } ``` **A silence is not a turn.** An utterance that transcribes to nothing ends there. The agent is never asked and nothing is spoken. The segmenter opens on any loud frame, so a cough, a door or a second of traffic reaches the transcriber, and waking the agent for every one of them is a bill and a caller being answered at random. Note the difference from the case below: an empty transcript is silent, a transcriber that fails says so out loud. ### Nothing throws into the call A step that fails is logged, and the caller hears a sentence. | When | What is spoken | | ------------------ | --------------------------------------------------------------------- | | `transcribe` fails | `TROUBLE_HEARING`, "Sorry, I did not catch that." | | `answer` fails | `TROUBLE_ANSWERING`, "Sorry, I am having trouble answering just now." | | `synthesize` fails | `TROUBLE_SPEAKING`, "Sorry, I am having trouble speaking just now." | Silence is the one thing a caller cannot interpret. Somebody on a phone call cannot tell a broken transcriber from an agent that is thinking, so they wait, and then they hang up. `TROUBLE_SPEAKING` goes back through your own `synthesize`, once. The retry is guarded, because an engine that is down cannot say the sentence about being down, and the lane goes quiet rather than looping on it. All three are exported from `@komaa/standin-sdk`, so a test can assert on the sentence itself. ### A long answer, spoken as it is written Return an async iterable from `answer` and each piece is synthesized and played as it arrives. ```ts theme={null} async function* myAgent(heard: string) { for await (const sentence of llm.stream(heard)) yield sentence; } ``` The caller hears the beginning of a long answer while the rest is still being written, which is the whole point: a model that takes four seconds to finish a paragraph leaves four seconds of silence otherwise. Yield whole sentences. Each piece is one `synthesize` call and one spoken buffer, so a token at a time is a synthesis request per word and speech chopped into syllables. Pieces that are blank are skipped. An interruption stops the loop, and what was never reached is never synthesized and never spoken. The generation is re-checked before each piece, so a stream that has been superseded goes quiet at the next piece rather than at the end of the answer. Leaving the loop closes the generator, so a `finally` inside it still runs. ### Saying a line nobody asked for A greeting, a handover, something that arrived from outside the call. ```ts theme={null} const turn = await lane.say("Thanks for taking the call."); ``` `say` skips `answer` and goes straight to synthesis and paced playback, so the line can be interrupted like any other answer, and it queues behind whatever is already speaking rather than overlapping it. It returns the `VoiceTurn`, which is how you find out whether it was heard to the end. It does not supersede a turn in flight, so call `bargeIn()` first when the line has to come before what is being said. ### Closing the lane `aclose()` drops the buffered audio, resets the segmenter, retires the turn in flight and waits for it to settle. Nothing cancels a request that is already in flight, so that wait is as long as your slowest step has left to run. Call it from your handler's `aclose`, once. After that, `feed` is a no-op rather than an error. Frames keep arriving for a moment after teardown begins, and the end of a call is the worst place to start throwing from the receive path. ### What each turn leaves behind `VoiceTurn` is the record of one exchange: `heard`, `said`, `interrupted` and `error`. `interrupted` is not a failure. It is the most common way a real conversation goes. Pass `onTurn` to receive one per caller turn the agent answered, and you have a transcript for free: ```ts theme={null} import { VoiceLane, type VoiceTurn } from "@komaa/standin-sdk"; const remember = (turn: VoiceTurn) => { transcript.add(callerName, turn.heard); transcript.add("Assistant", turn.said, "assistant"); }; const lane = new VoiceLane(session, myStt, myAgent, myTts, { onTurn: remember }); ``` It is a plain function, not an async one, and it is called inline at the end of a turn, so keep it short. An exception it throws is swallowed: a plugin's own bookkeeping must not end a call. A turn the transcriber or the agent failed is spoken but not reported, so this is a record of the conversation rather than a count of what went wrong. An answer that came back empty is not reported either, because nothing was said. Nor is a line from `say`: it hands you its `VoiceTurn` as the return value instead, so a greeting is missing from a transcript built only from `onTurn`. A synthesis failure is the one that does reach it, with the trouble sentence as `said` and the engine's message in `error`. `error` is `undefined` on a turn that went through cleanly, so check it before filing a turn as something the agent meant to say. Three more things on the options object and the lane itself. `segmenter` takes an `UtteranceSegmenter` you tuned yourself, with the five bounds above. `speaking` is true while audio is going out, and `busy` covers the whole turn, including the model's own thinking. `turn` is the promise for the turn in flight, or `undefined`, for a test or a teardown that wants to let one finish. ## What this page does not cover Two more shared lanes sit next to this one and are worth knowing exist before you write them yourself. * **The echo guard.** On a speakerphone the agent's own voice comes back in, the model's voice detection hears it, and the agent answers itself in a loop. `shouldSuppressEcho` is what stops it, on a playout clock rather than a wall clock. See [Realtime providers](/typescript-sdk/realtime-providers#the-agent-answering-itself). * **The meeting gate.** In a group call the prior question is whether to answer at all. `GroupGate` decides, and is inert on a 1:1 call. See [Group calls](/typescript-sdk/group-calls). ## Next `resamplePcm16`, `FrameAligner` and `pcm16Rms`, which this lane is built on. Deciding which finished utterance was even meant for the agent. The other shape: one provider owning the whole turn. What to do with the `VoiceTurn` stream `onTurn` hands you.