docs: add documentation, update README, add marketing page

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ValueOn AG 2026-02-18 17:51:28 +01:00
parent 777bc198a2
commit 6b4172c46a
3 changed files with 1273 additions and 146 deletions

704
DOCUMENTATION.md Normal file
View file

@ -0,0 +1,704 @@
# Teams Browser Bot — Technical Documentation
Last updated: 2026-02-18
## Table of Contents
1. [Business Story & Vision](#1-business-story--vision)
2. [Use Cases](#2-use-cases)
3. [System Architecture](#3-system-architecture)
4. [Components](#4-components)
5. [Data Model](#5-data-model)
6. [Call Flow](#6-call-flow)
7. [Voice Flow (TTS Playback)](#7-voice-flow-tts-playback)
8. [Data Flow (Transcript Pipeline)](#8-data-flow-transcript-pipeline)
9. [WebSocket Protocol](#9-websocket-protocol)
10. [AI Analysis Pipeline](#10-ai-analysis-pipeline)
11. [Authentication & Credentials](#11-authentication--credentials)
12. [Teams DOM Interaction](#12-teams-dom-interaction)
13. [Deployment](#13-deployment)
14. [Configuration Reference](#14-configuration-reference)
15. [Known Constraints & Lessons Learned](#15-known-constraints--lessons-learned)
---
## 1. Business Story & Vision
### Problem
Organizations use Microsoft Teams for meetings where important decisions, discussions, and action items occur. Without an automated assistant, teams rely on manual note-taking, miss context from earlier discussions, and lose the ability to query meeting content in real time.
### Solution
The Teams Browser Bot is an AI-powered meeting participant that:
- **Joins** any Teams meeting as an authenticated user (or anonymous guest)
- **Listens** by capturing live captions from the Teams web interface
- **Understands** by analyzing transcript segments through an AI model (GPT-4o-mini / Claude)
- **Responds** via voice (TTS played through the microphone channel) and/or chat messages
- **Documents** by persisting full transcripts and generating meeting summaries
The bot operates as a real participant — it appears in the meeting roster, can speak, and can write in the meeting chat.
### Key Differentiator
Unlike Microsoft Graph Communications SDK bots (which require tenant admin registration and complex media handling), this bot uses **browser automation** (Playwright + Chromium) to join meetings as a regular web user. This enables:
- **Multi-tenant support**: Join any meeting from any organization
- **No tenant admin approval** required
- **Standard web technologies**: DOM scraping, getUserMedia, WebRTC
- **Full meeting interaction**: chat, captions, audio playback
---
## 2. Use Cases
### UC-1: AI Meeting Assistant
A user starts a Teams meeting and invites the bot. The bot joins, listens to the conversation, and responds when addressed by name ("Hey Nyla, what do you think about...?"). Responses are delivered via voice and/or chat based on configuration.
### UC-2: Live Transcription
The bot captures all live captions with speaker attribution and streams them to the frontend UI in real time via SSE. Users not in the meeting can follow along.
### UC-3: Meeting Summary
When the session ends, the bot generates an AI-powered summary of the entire meeting, stored on the session record.
### UC-4: Voice Test
Before joining a real meeting, integrators can test the TTS pipeline via a dedicated endpoint that generates and returns an audio sample.
### UC-5: Multi-Bot Operations
Multiple bot sessions can run concurrently — each in its own browser instance, each connected to a different meeting with separate WebSocket channels.
---
## 3. System Architecture
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ System Overview │
│ │
│ ┌──────────┐ SSE ┌────────────────┐ WebSocket │
│ │ Frontend │◄───────────────────────│ Gateway │◄──────────────────────┐ │
│ │ (React) │ transcripts, │ (Python / │ transcripts, │ │
│ │ │ botResponses, │ FastAPI) │ chatMessages, │ │
│ │ │ analysis, │ │ status, │ │
│ │ │ status │ - Session Mgmt │ audioChunks, │ │
│ │ │────────────────────────► - AI Analysis │ voiceGreeting │ │
│ │ │ REST (start/stop/ │ - TTS (Google) │ │ │
│ │ │ config) │ - Billing │ playAudio, │ │
│ └──────────┘ │ - DB (Cosmos) │ sendChatMessage, │ │
│ │ │ stopAudio │ │
│ └────────┬───────┘──────────────────────►│ │
│ │ HTTP │ │
│ │ (join/leave/status) │ │
│ ▼ │ │
│ ┌────────────────┐ │ │
│ │ Browser Bot │◄──────────────────────┘ │
│ │ (Node.js + │ │
│ │ Playwright) │ │
│ │ │ │
│ │ ┌──────────┐ │ │
│ │ │ Chromium │ │ │
│ │ │ (Teams │ │ │
│ │ │ Web App) │ │ │
│ │ └──────────┘ │ │
│ └────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
```
### Communication Paths
| Path | Protocol | Direction | Purpose |
|------|----------|-----------|---------|
| Frontend ↔ Gateway | REST (HTTPS) | Bidirectional | Session management, config, system bots |
| Frontend ← Gateway | SSE | Gateway → Frontend | Real-time transcript & response stream |
| Gateway ↔ Browser Bot | WebSocket | Bidirectional | Transcripts, audio, status, chat, commands |
| Gateway → Browser Bot | HTTP POST | Gateway → Bot | Session creation (`/api/bot`), leave, status |
| Browser Bot ↔ Teams | Chromium/WebRTC | Bidirectional | Meeting participation, captions, chat, audio |
---
## 4. Components
### 4.1 Browser Bot Service (this repository)
| File | Responsibility |
|------|---------------|
| `src/index.ts` | Entry point: bootstrap, shutdown handlers |
| `src/config.ts` | Environment config with defaults and timeouts |
| `src/sessionManager.ts` | Session lifecycle: create, end, play audio, shutdown |
| `src/server/httpServer.ts` | Express HTTP API (health, join, leave, status, auth tests) |
| `src/server/gatewayClient.ts` | Alternative WebSocket client for Gateway (legacy path) |
| `src/types/index.ts` | TypeScript interfaces for all message types |
| `src/utils/logger.ts` | Winston logger with session-scoped child loggers |
#### Bot Modules (`src/bot/`)
| Module | Class | Responsibility |
|--------|-------|---------------|
| `orchestrator.ts` | `BotOrchestrator` | Main coordinator: browser launch, join flow, keepalive, greeting, Gateway WebSocket, state machine |
| `joinProcedure.ts` | `JoinProcedure` | Anonymous join: launcher page, name entry, "Join now", lobby handling |
| `authProcedure.ts` | `AuthProcedure` | Microsoft login: email → password → MFA check → "Stay signed in" |
| `captionsProcedure.ts` | `CaptionsProcedure` | Enable live captions via "More" menu, MutationObserver on caption DOM |
| `chatProcedure.ts` | `ChatProcedure` | Open chat panel, MutationObserver on `[role="log"]`, send messages via CKEditor |
| `audioProcedure.ts` | `AudioProcedure` | getUserMedia override, AudioContext, queue-based MP3/WAV/PCM playback into mic stream |
| `audioCaptureProcedure.ts` | `AudioCaptureProcedure` | RTCPeerConnection wrapper, ScriptProcessor, PCM16 16kHz capture, 500ms polling |
| `backgroundProcedure.ts` | `BackgroundProcedure` | Virtual background image upload (pre-join, currently unused) |
| `meetingUrlParser.ts` | (functions) | URL validation, classic vs short format, redirect resolution |
| `authTestProcedure.ts` | (functions) | Anti-detection test variants for debugging auth flow |
### 4.2 Gateway (external, Python/FastAPI)
| File | Responsibility |
|------|---------------|
| `routeFeatureTeamsbot.py` | REST routes, SSE stream, WebSocket endpoint |
| `service.py` | Business logic: transcript processing, AI triggers, TTS, meeting summary |
| `datamodelTeamsbot.py` | Pydantic models, enums |
| `interfaceFeatureTeamsbot.py` | Database interface (Cosmos DB) |
| `config.py` | Feature instance config load/save |
| `browserBotConnector.py` | HTTP client for Browser Bot API |
### 4.3 Frontend (external, React/TypeScript)
The frontend provides a session management UI with:
- Meeting link input and session start/stop controls
- Real-time transcript display (via SSE)
- Bot response log with reasoning, model, cost
- Configuration panel (bot name, response channel, AI prompt, etc.)
- System bot management (email, password)
---
## 5. Data Model
### 5.1 Enums
| Enum | Values | Description |
|------|--------|-------------|
| `TeamsbotSessionStatus` | `pending`, `joining`, `active`, `leaving`, `ended`, `error` | Session lifecycle state |
| `TeamsbotResponseType` | `audio`, `chat`, `both` | How the bot responded |
| `TeamsbotResponseChannel` | `voice`, `chat`, `both` | Configured response channel (user setting) |
| `TeamsbotResponseMode` | `auto`, `manual`, `transcribeOnly` | Whether bot responds automatically |
| `TeamsbotDetectedIntent` | `addressed`, `question`, `proactive`, `stop`, `none` | AI-detected intent |
| `TeamsbotJoinMode` | `systemBot`, `anonymous`, `userAccount` | How the bot joins the meeting |
| `TeamsbotTransferMode` | `caption`, `audio`, `auto` | How transcript data is captured |
### 5.2 Core Entities
#### TeamsbotSession
| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Session identifier |
| `instanceId` | string | Feature instance |
| `mandateId` | string | Tenant/mandate |
| `meetingLink` | string | Teams meeting URL |
| `botName` | string | Display name in meeting |
| `status` | TeamsbotSessionStatus | Current state |
| `startedAt` / `endedAt` | datetime | Timestamps |
| `startedByUserId` | string | Who started the session |
| `sessionContext` | string | Optional context for AI |
| `summary` | string | AI-generated meeting summary |
| `errorMessage` | string | Error details if failed |
| `transcriptSegmentCount` | int | Running count |
| `botResponseCount` | int | Running count |
#### TeamsbotTranscript
| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Segment identifier |
| `sessionId` | UUID | Parent session |
| `speaker` | string | Speaker name from captions |
| `text` | string | Transcript text |
| `timestamp` | datetime | When spoken |
| `confidence` | float (01) | Confidence score |
| `language` | string | Detected language |
| `isFinal` | bool | Finalized segment |
#### TeamsbotBotResponse
| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Response identifier |
| `sessionId` | UUID | Parent session |
| `responseText` | string | What the bot said |
| `responseType` | TeamsbotResponseType | Voice, chat, or both |
| `detectedIntent` | TeamsbotDetectedIntent | Why it responded |
| `reasoning` | string | AI reasoning chain |
| `modelName` | string | AI model used |
| `processingTime` | float | Seconds |
| `priceCHF` | float | Cost in CHF |
#### TeamsbotSystemBot
| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Bot account identifier |
| `mandateId` | string | Tenant scope |
| `name` | string | Display name |
| `email` | string | Microsoft account email |
| `encryptedPassword` | string | Fernet-encrypted password |
| `isActive` | bool | Whether this bot is the active one |
#### TeamsbotConfig (Feature Instance Level)
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `botName` | string | `"PowerOn AI"` | Default bot name (overridden by system bot) |
| `aiSystemPrompt` | string | `""` | Custom AI instructions |
| `responseMode` | enum | `auto` | auto / manual / transcribeOnly |
| `responseChannel` | enum | `voice` | voice / chat / both |
| `transferMode` | enum | `auto` | caption / audio / auto |
| `language` | string | `"de-DE"` | Bot language |
| `voiceId` | string | `null` | TTS voice identifier |
| `browserBotUrl` | string | `null` | Browser Bot service URL |
| `triggerIntervalSeconds` | int | `10` | Periodic AI trigger interval |
| `triggerCooldownSeconds` | int | `5` | Min time between triggers |
| `contextWindowSegments` | int | `20` | Transcript segments sent to AI |
#### TeamsbotUserSettings (Per-User Overrides)
Mirrors `TeamsbotConfig` fields (all optional). Merged over instance config with `_getEffectiveConfig()`.
---
## 6. Call Flow
### 6.1 Session Start (Authenticated Join)
```
Frontend Gateway Browser Bot Teams Web
│ │ │ │
│ POST /sessions │ │ │
│ {meetingLink, botName} │ │ │
│──────────────────────────►│ │ │
│ │ Resolve system bot │ │
│ │ Decrypt password │ │
│ │ Derive bot name from email│ │
│ │ │ │
│ │ POST /api/bot │ │
│ │ {sessionId, meetingUrl, │ │
│ │ botAccountEmail, │ │
│ │ botAccountPassword} │ │
│ │───────────────────────────►│ │
│ │ │ │
│ ◄── session created ─────│ │ Launch Chromium │
│ │ │ (Xvfb, headful) │
│ GET /sessions/:id/stream │ │ │
│ (SSE) │ │ │
│──────────────────────────►│ │ │
│ │◄─── WebSocket connected ───│ │
│ │ │ │
│ │ │ Navigate to │
│ │ │ teams.microsoft.com │
│ │ │─────────────────────►│
│ │ │ │
│ │ │ MS Login: │
│ │ │ email → password │
│ │ │ → "Stay signed in" │
│ │ │─────────────────────►│
│ │ │ │
│ │ │ Teams loads │
│ │ │ Click "Join" header │
│ │ │◄─────────────────────│
│ │ │ │
│ │ │ Pre-join screen: │
│ │ │ Ensure mic ON │
│ │ │ Camera stays OFF │
│ │ │ Click "Join now" │
│ │ │─────────────────────►│
│ │ │ │
│ ◄── SSE: statusChange ───│◄─── status: "joined" ─────│ In meeting! │
│ {status: "active"} │ │ │
│ │ │ Start keepalive │
│ │ │ Init AudioContext │
│ │ │ Enable captions │
│ │ │ Enable chat │
│ │ │ Send greeting │
│ │ │ (chat + voice TTS) │
```
### 6.2 Session End
```
Frontend Gateway Browser Bot Teams
│ │ │ │
│ POST /sessions/:id/stop │ │ │
│──────────────────────────►│ │ │
│ │ POST /api/bot/:id/leave │ │
│ │───────────────────────────►│ │
│ │ │ Stop keepalive │
│ │ │ Stop audio capture │
│ │ │ Unsubscribe captions │
│ │ │ Unsubscribe chat │
│ │ │ Click hangup button │
│ │ │─────────────────────►│
│ │ │ Close browser │
│ │ │ Close WS │
│ │◄─── status: "left" ────────│ │
│ │ │ │
│ │ Generate meeting summary │ │
│ │ (AI on full transcript) │ │
│ │ Update session → "ended" │ │
│ ◄── SSE: statusChange ───│ │ │
│ {status: "ended"} │ │ │
```
---
## 7. Voice Flow (TTS Playback)
### How Audio Reaches Meeting Participants
```
Gateway Browser Bot Chromium / Teams
│ │ │
│ AI generates response │ │
│ TTS (Google Cloud) → │ │
│ base64 MP3 │ │
│ │ │
│ WS: playAudio │ │
│ {audio: {data, format}} │ │
│────────────────────────────►│ │
│ │ Queue audio │
│ │ Decode base64 → ArrayBuffer │
│ │ decodeAudioData(buffer) │
│ │ │
│ │ Create AudioBufferSource │
│ │ Connect to │
│ │ MediaStreamDestination │
│ │ (overridden getUserMedia) │
│ │─────────────────────────────►│
│ │ │ WebRTC sends audio
│ │ │ to all participants
│ │ │ via microphone channel
```
### Audio Override Mechanism
The `AudioProcedure` injects a script before page load (`page.addInitScript`) that:
1. Overrides `navigator.mediaDevices.getUserMedia` to return a custom `MediaStream`
2. Creates a shared `AudioContext` with a `MediaStreamDestination`
3. When Teams calls `getUserMedia({ audio: true })`, it receives the destination's stream
4. TTS audio is decoded and played through an `AudioBufferSourceNode` connected to this destination
5. The result: TTS audio flows through the "microphone" channel into the meeting
### Voice Greeting Flow
When the bot joins a meeting:
1. Bot sends `voiceGreeting` message to Gateway with greeting text and language
2. Gateway calls TTS (Google Cloud) with the configured voice
3. Gateway sends `playAudio` back to bot via WebSocket
4. Bot plays the audio through the mic stream
5. Meeting participants hear the bot speaking
---
## 8. Data Flow (Transcript Pipeline)
### Caption Mode (Primary)
```
Teams Web UI Browser Bot Gateway Frontend
│ │ │ │
│ Live captions appear │ │ │
│ in overlay div │ │ │
│ [data-tid="closed- │ │ │
│ caption-renderer- │ │ │
│ wrapper"] │ │ │
│ │ │ │
│ MutationObserver fires │ │ │
│───────────────────────────►│ │ │
│ │ Extract speaker + text │ │
│ │ Dedup, noise filter │ │
│ │ │ │
│ │ WS: transcript │ │
│ │ {speaker, text, isFinal} │ │
│ │────────────────────────────►│ │
│ │ │ Store in DB │
│ │ │ Add to context buffer │
│ │ │ Emit SSE: transcript │
│ │ │───────────────────────►│
│ │ │ │ Display
│ │ │ │
│ │ │ _shouldTriggerAnalysis│
│ │ │ → if yes: │
│ │ │ SPEECH_TEAMS AI call │
│ │ │ │
│ │ │ Emit SSE: analysis │
│ │ │───────────────────────►│
│ │ │ │
│ │ │ If shouldRespond: │
│ │ │ TTS → playAudio │
│ │ ◄── WS: playAudio ────────│ and/or sendChatMessage│
│ │ │───────────────────────►│
│ │ │ Emit SSE: botResponse │
```
### Audio Mode (Alternative)
When `transferMode` is `audio`:
1. `AudioCaptureProcedure` wraps `RTCPeerConnection` to intercept incoming audio tracks
2. Audio is downsampled to PCM16 mono 16kHz via `ScriptProcessorNode`
3. 500ms chunks are base64-encoded and sent as `audioChunk` messages
4. Gateway runs STT (Google Cloud Speech) on the chunks
5. STT results enter the same transcript pipeline
### Chat Messages
```
Teams Chat Panel Browser Bot Gateway Frontend
│ │ │ │
│ New message in │ │ │
│ [role="log"] container │ │ │
│ │ │ │
│ MutationObserver fires │ │ │
│───────────────────────────►│ │ │
│ │ Extract sender + text │ │
│ │ Dedup, noise filter │ │
│ │ │ │
│ │ WS: chatMessage │ │
│ │ {chat: {speaker, text}} │ │
│ │────────────────────────────►│ │
│ │ │ Process as transcript │
│ │ │ (source: "chat") │
│ │ │ Same AI pipeline │
```
---
## 9. WebSocket Protocol
### Connection
The Browser Bot connects to the Gateway at:
```
wss://{gatewayHost}/api/teamsbot/{instanceId}/bot/ws/{sessionId}
```
### Messages: Bot → Gateway
| Type | Fields | When |
|------|--------|------|
| `transcript` | `sessionId`, `transcript: {speaker, text, timestamp, isFinal}` | Caption captured |
| `chatMessage` | `sessionId`, `chat: {speaker, text, timestamp}` | Meeting chat message received |
| `status` | `sessionId`, `status`, `message?` | Bot state changes (connecting, in_lobby, joined, left, error) |
| `audioChunk` | `sessionId`, `audio: {data, sampleRate, format, timestamp}` | PCM16 audio captured (audio mode) |
| `voiceGreeting` | `sessionId`, `text`, `language` | Request TTS for join greeting |
| `ping` | — | Keepalive (every 30s) |
### Messages: Gateway → Bot
| Type | Fields | When |
|------|--------|------|
| `playAudio` | `sessionId`, `audio: {data, format}` | TTS response or greeting to play |
| `sendChatMessage` | `sessionId`, `text` | Chat response to send |
| `stopAudio` | `sessionId` | AI detected "stop" intent |
| `pong` | — | Reply to ping |
---
## 10. AI Analysis Pipeline
### Trigger Logic (`_shouldTriggerAnalysis`)
The Gateway decides when to call the AI model. Three trigger paths:
1. **Name Trigger (highest priority, overrides cooldown)**: If the bot's name (or first name, or a phonetically similar word) appears in the latest transcript segment → immediate trigger. Phonetic matching uses: same first letter, length difference ≤ 2, character overlap ≥ 60%.
2. **Cooldown Gate**: If time since last AI call < `triggerCooldownSeconds` (default 5s) → no trigger.
3. **Periodic Trigger**: If time since last AI call ≥ `triggerIntervalSeconds` (default 10s) → trigger.
### AI Call (`_handleSpeechTeams`)
**Model selection priority**: gpt-4o-mini → claude-3-5-haiku → gpt-4o → claude-sonnet-4-5 → fastest available DATA_ANALYSE model.
**System prompt** (built dynamically with bot name):
- Role: "You are '{botName}', an AI participant in a Teams meeting"
- Respond ONLY when directly addressed by name (including phonetic variants)
- Match the language of the speaker who addressed you
- 12 sentence responses max
- Detect "stop" commands in any language
- Output strict JSON: `{shouldRespond, responseText, reasoning, detectedIntent}`
**Context window**: Up to `contextWindowSegments` (default 20) recent transcript lines, prefixed with `BOT_NAME:` and optional `SESSION_CONTEXT:`.
### Response Handling
| Intent | Action |
|--------|--------|
| `stop` | Send `stopAudio` to bot, no response |
| `addressed` / `question` / `proactive` | Auto mode: TTS + chat (per config). Manual mode: SSE `suggestedResponse` only |
| `none` | No action |
---
## 11. Authentication & Credentials
### Credential Storage
System bot credentials are stored per mandate:
- **Email**: Stored in plaintext in `TeamsbotSystemBot.email`
- **Password**: Encrypted with Fernet (AES-128-CBC), key derived via PBKDF2 from a master key
- **Decryption**: Gateway decrypts at session start and passes credentials to Browser Bot via HTTP POST body
### Microsoft Login Flow
```
1. Navigate to teams.microsoft.com
2. Redirect to login.microsoftonline.com
3. Enter email in #i0116 input → Click "Next" (input[type="submit"])
4. Wait for password page (may redirect to org-specific login)
5. Enter password → Click "Sign in" (#idSIButton9)
6. Handle "Stay signed in?" → Click "Yes"
7. Teams loads with authenticated session
```
### Anti-Detection
The bot uses several measures to appear as a regular browser:
- `rebrowser-playwright` with `puppeteer-extra-plugin-stealth`
- Headful mode via Xvfb (Teams blocks headless Chromium)
- Standard Chrome launch arguments (disable automation, sandbox flags)
- Real viewport (1280x720), locale/timezone matching
---
## 12. Teams DOM Interaction
### Live Captions
**Enable flow**: "More" button (`#callingButtons-showMoreBtn`) → "Language and speech" (`#LanguageSpeechMenuControl-id`) → "Show live captions" (button with `aria-checked`)
**Scraping target**: `div[data-tid="closed-caption-renderer-wrapper"]`
**Extraction strategies**:
- Strategy A: `[data-tid]` containers with speaker in `<span>` title/text, content in adjacent spans
- Strategy B: Structural fallback scanning `<div>` trees for speaker + text patterns
**Noise filter**: Ignores entries matching known non-transcript patterns (buttons, timestamps without content, single-word UI elements).
### Chat Panel
**Open**: Click `button[id="chat-button"]` — but ONLY if `aria-pressed !== "true"` (prevents toggle-off)
**Scraping target**: Container with `[role="log"]`
**Send messages**: Find CKEditor input (`[data-tid="ckeditor-replyConversation"]` or `div[role="textbox"]`), type text, press Enter.
### Meeting Controls
| Control | Selector | Notes |
|---------|----------|-------|
| Mic toggle | `input[data-tid="toggle-audio"]` or `input[role="switch"][title*="mic" i]` | Check `checked` state before toggling |
| Hangup | `button[id="hangup-button"]` or `#hangup-button` | |
| More menu | `button[id="callingButtons-showMoreBtn"]` | |
| Join now | `button[data-tid="prejoin-join-button"]` | |
---
## 13. Deployment
### Infrastructure
| Component | Platform | Details |
|-----------|----------|---------|
| Browser Bot | Azure Container Apps | 2 CPU, 4GB RAM, Xvfb for headful mode |
| Gateway | Azure Container Apps | Shared instance (`cae-poweron-shared`) |
| Database | Azure Cosmos DB | Sessions, transcripts, responses, system bots |
| Container Registry | Azure Container Registry | Images tagged by Git SHA |
### CI/CD Pipeline
**Trigger**: Push to `main` branch
**Steps**:
1. Checkout code
2. Docker login to ACR
3. Build image with Playwright base + Xvfb
4. Push with tags `latest` and `{git-sha}`
5. Azure login
6. `az containerapp update` with `--revision-suffix deploy-{sha-prefix}` (forces new revision)
7. `az containerapp revision restart` on latest revision (ensures container starts)
### Environment Variables (Production)
| Variable | Value |
|----------|-------|
| `PORT` | `4100` |
| `NODE_ENV` | `production` |
| `BOT_HEADLESS` | `false` (headful via Xvfb) |
| `GATEWAY_WS_URL` | `wss://gateway-int.poweron-center.net/api/teamsbot/ws` |
| `DISPLAY` | `:99` (Xvfb) |
---
## 14. Configuration Reference
### Browser Bot Environment
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | HTTP server port | `4100` |
| `GATEWAY_WS_URL` | Gateway WebSocket URL | `wss://gateway-int.poweron-center.net/api/teamsbot/ws` |
| `BOT_NAME` | Default display name | `PowerOn AI` |
| `BOT_HEADLESS` | Run headless | `true` (`false` in Docker) |
| `LOG_LEVEL` | Winston log level | `info` |
| `SCREENSHOT_ON_ERROR` | Screenshots on errors | `true` |
### Gateway Feature Config
| Field | Default | Range | Description |
|-------|---------|-------|-------------|
| `responseMode` | `auto` | — | auto, manual, transcribeOnly |
| `responseChannel` | `voice` | — | voice, chat, both |
| `transferMode` | `auto` | — | caption, audio, auto |
| `language` | `de-DE` | — | BCP-47 language tag |
| `triggerIntervalSeconds` | `10` | 360 | Periodic AI trigger |
| `triggerCooldownSeconds` | `5` | 130 | Min gap between triggers |
| `contextWindowSegments` | `20` | 5100 | Transcript lines for AI context |
### Timeouts (Hardcoded in config.ts)
| Timeout | Value | Purpose |
|---------|-------|---------|
| `lobbyWait` | 120s | Max time waiting in lobby |
| `joinTimeout` | 30s | Max time for join flow |
| `captionsEnable` | 10s | Max time to enable captions |
| `pageLoad` | 30s | General page load timeout |
---
## 15. Known Constraints & Lessons Learned
### Things That DO NOT Work
| Approach | Why It Fails | Alternative |
|----------|-------------|-------------|
| Fake video injection (`--use-file-for-fake-video-capture`) | Crashes Chromium renderer when WebRTC audio starts | Keep camera OFF |
| Headless Chromium | Teams detects and blocks headless browsers | Use headful + Xvfb |
| `_setSpokenLanguage()` for live captions | Language dropdown doesn't exist; captions use organizer's language setting | Skip entirely |
| `str(PythonEnum)` for comparison | Returns `"ClassName.value"`, not `"value"` | Use `enum.value` |
| Blindly clicking chat button | Toggles panel off if already open | Check `aria-pressed` first |
| `config.botAccountEmail` | `TeamsbotConfig` doesn't have this field | Use `getattr(self, '_botAccountEmail', None)` |
| `model_copy(update={...})` for enum fields | Pydantic v2 doesn't coerce strings to enums on copy | Normalize after merge |
### Speech Recognition Artifacts
The bot's name gets mangled by Teams live captions speech recognition. Known variants for "Nyla": Naila, Maila, Neela, Leila, Nila. The system handles this via:
- **AI prompt**: Explicit warning about phonetic distortion
- **Trigger logic**: Phonetic similarity check (first letter match, length ±2, character overlap ≥ 60%)
### SSE Event Queue: Create On-Demand
`_emitSessionEvent()` in the Gateway must create the session's event queue (`_sessionEvents[sessionId] = asyncio.Queue()`) if it doesn't already exist. If an event is emitted before the frontend SSE consumer connects, the queue won't exist and events are silently dropped. Always guard with on-demand creation.
### Performance Characteristics
| Operation | Typical Duration |
|-----------|-----------------|
| Microsoft login | ~14s |
| Teams page load after auth | ~10s |
| Pre-join to in-meeting | ~5s |
| Caption enable flow | ~8s |
| AI analysis (GPT-4o-mini) | ~2.5s |
| TTS generation | ~1s |
| Full join to first greeting | ~40s |

394
README.md
View file

@ -1,67 +1,269 @@
# Teams Browser Bot Service
# Teams Bot Service
Browser-based Microsoft Teams Meeting Bot using Playwright. This service joins Teams meetings via the web interface, captures live captions, and plays TTS audio responses.
Last rev. 2026-01-15
AI-powered Microsoft Teams meeting bot. Joins meetings, captures live transcripts, monitors chat, and responds via voice and/or chat messages.
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Browser-based Architecture │
│ │
│ ┌───────────────────┐ ┌───────────────────────────────┐ │
│ │ Gateway │ │ Browser Bot Service │ │
│ │ (Python) │ │ (Node.js + Playwright) │ │
│ │ │ │ │ │
│ │ - STT (Google) │ WebSocket │ - Headless Chrome │ │
│ │ - AI (OpenAI) │◄──────────────────►│ - Teams Web App │ │
│ │ - TTS (Google) │ Transcripts │ - Meeting join flow │ │
│ │ - Session Mgmt │ + TTS Audio │ - Captions scraping │ │
│ │ │ │ - Audio playback │ │
│ └───────────────────┘ └───────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
┌───────────────┐ ┌───────────────────────┐
│ │ WebSocket │ │
│ Gateway │◄──────────────────────────────────►│ Bot Service │
│ │ transcripts, status, chat, │ │
│ ● AI engine │ audioChunks, voiceGreeting │ Joins Teams meetings │
│ ● TTS │ │ as a participant │
│ ● Sessions │ playAudio, sendChatMessage, │ │
│ ● Billing │ stopAudio │ Capabilities: │
│ │ │ ├ Live transcripts │
│ │ HTTP │ ├ Chat messages │
│ │────────────────────────────────────► ├ Voice playback │
└───────────────┘ join, leave, status │ └ Audio capture │
└───────────────────────┘
```
## Features
| Path | Protocol | Purpose |
|------|----------|---------|
| Gateway ↔ Bot | WebSocket | Real-time transcript, chat, audio, status exchange |
| Gateway → Bot | HTTP | Session control (join, leave, status) |
- **Multi-tenant support**: Can join any Teams meeting (not limited to own tenant)
- **Browser-based**: Uses Teams web app, no Graph Communications SDK needed
- **Captions scraping**: Captures live captions for transcription
- **Audio playback**: Plays TTS audio through the browser into the meeting
- **WebSocket integration**: Real-time communication with Gateway
## Integration Guide
## Prerequisites
### How It Works
- Node.js 18+
- Docker (for production deployment)
1. Gateway sends a **POST** to the Bot with a meeting URL and optional credentials
2. Bot joins the meeting and appears as a regular participant
3. A **WebSocket** connection is established for real-time data exchange
4. Bot streams transcript segments and chat messages to the Gateway
5. Gateway can send TTS audio or chat responses back into the meeting
## Quick Start
### Step 1: Start a Session
### Local Development
```http
POST /api/bot
Content-Type: application/json
{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"meetingUrl": "https://teams.microsoft.com/meet/123456789?p=abc123",
"botName": "AI Assistant",
"instanceId": "feature-instance-uuid",
"gatewayWsUrl": "wss://gateway.example.com/api/teamsbot/ws",
"language": "de-DE",
"botAccountEmail": "bot@example.com",
"botAccountPassword": "decrypted-password",
"transferMode": "caption"
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `sessionId` | Yes | Unique session UUID (generated by Gateway) |
| `meetingUrl` | Yes | Teams meeting URL (classic or short format) |
| `botName` | No | Display name in meeting (default: env `BOT_NAME`) |
| `instanceId` | No | Feature instance ID for Gateway WebSocket path |
| `gatewayWsUrl` | No | Gateway WebSocket base URL (default: env `GATEWAY_WS_URL`) |
| `language` | No | BCP-47 language code (default: `de-DE`) |
| `botAccountEmail` | No | Microsoft account for authenticated join |
| `botAccountPassword` | No | Decrypted password for authenticated join |
| `transferMode` | No | `caption` or `audio` |
If `botAccountEmail` + `botAccountPassword` are provided, the bot joins as an authenticated user. Otherwise, it joins as an anonymous guest.
**Response:**
```json
{
"success": true,
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"message": "Bot deployment initiated"
}
```
### Step 2: Receive Data via WebSocket
After deployment, the Bot connects to the Gateway at:
```
wss://{gatewayHost}/api/teamsbot/{instanceId}/bot/ws/{sessionId}
```
#### Messages: Bot → Gateway
**Transcript segment:**
```json
{
"type": "transcript",
"sessionId": "...",
"transcript": {
"speaker": "Jane Doe",
"text": "Hey Bot, can you summarize this?",
"timestamp": "2026-02-18T10:30:00.000Z",
"isFinal": true
}
}
```
**Chat message:**
```json
{
"type": "chatMessage",
"sessionId": "...",
"chat": {
"speaker": "Jane Doe",
"text": "Please summarize the discussion",
"timestamp": "2026-02-18T10:31:00.000Z"
}
}
```
**Status update:**
```json
{
"type": "status",
"sessionId": "...",
"status": "joined",
"message": "Bot joined the meeting"
}
```
Status values: `connecting` | `in_lobby` | `joined` | `left` | `error`
**Voice greeting request** (bot asks Gateway for TTS):
```json
{
"type": "voiceGreeting",
"sessionId": "...",
"text": "Hello, I am ready.",
"language": "de-DE"
}
```
**Raw audio chunk** (`transferMode: "audio"` only):
```json
{
"type": "audioChunk",
"sessionId": "...",
"audio": {
"data": "<base64-encoded PCM16>",
"sampleRate": 16000,
"format": "pcm16",
"timestamp": "2026-02-18T10:30:00.500Z"
}
}
```
**Keepalive:**
```json
{ "type": "ping" }
```
#### Messages: Gateway → Bot
**Play TTS audio:**
```json
{
"type": "playAudio",
"sessionId": "...",
"audio": {
"data": "<base64-encoded MP3>",
"format": "mp3"
}
}
```
**Send chat message:**
```json
{
"type": "sendChatMessage",
"sessionId": "...",
"text": "Here is my summary of the discussion..."
}
```
**Stop audio playback:**
```json
{ "type": "stopAudio", "sessionId": "..." }
```
**Keepalive response:**
```json
{ "type": "pong" }
```
### Step 3: Leave the Meeting
```http
POST /api/bot/:sessionId/leave
```
```json
{
"success": true,
"message": "Leave initiated"
}
```
### Step 4: Check Status
```http
GET /api/bot/:sessionId/status
```
```json
{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"state": "in_meeting",
"error": null
}
```
### Health Check
```http
GET /health
```
Returns `200 OK` with `{ "status": "ok", "timestamp": "..." }`.
## Meeting URL Formats
Both formats are supported:
```
# Classic
https://teams.microsoft.com/l/meetup-join/19%3ameeting_xxx/0?context=...
# Short
https://teams.microsoft.com/meet/123456789?p=abc123
```
## Running the Service
### Local
```bash
# Install dependencies
npm install
# Install Playwright browsers
npx playwright install chromium
# Copy and configure environment
cp .env.sample .env
# Edit .env with your settings
# Run in development mode
npm run dev
cp .env.sample .env # Configure Gateway URL, bot name, etc.
npm run dev # Dev mode
```
### Docker
```bash
# Build and run
docker-compose up --build
docker build -t teams-bot .
# Or build image only
docker build -t teams-browser-bot .
docker run -p 4100:4100 \
-e GATEWAY_WS_URL=wss://gateway.example.com/api/teamsbot/ws \
teams-bot
```
## Configuration
@ -69,106 +271,6 @@ docker build -t teams-browser-bot .
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | HTTP server port | `4100` |
| `GATEWAY_WS_URL` | Gateway WebSocket URL | `wss://gateway-int.poweron-center.net/api/teamsbot/ws` |
| `BOT_NAME` | Display name in meetings | `PowerOn AI` |
| `BOT_HEADLESS` | Run browser headless | `true` |
| `LOG_LEVEL` | Logging level | `info` |
| `SCREENSHOT_ON_ERROR` | Take screenshots on errors | `true` |
## API Endpoints
### Health Check
```
GET /health
```
### Deploy Bot
```
POST /api/bot
Content-Type: application/json
{
"sessionId": "uuid",
"meetingUrl": "https://teams.microsoft.com/meet/...",
"botName": "PowerOn AI"
}
```
### Leave Meeting
```
POST /api/bot/:sessionId/leave
```
### Get Status
```
GET /api/bot/:sessionId/status
```
## WebSocket Protocol
### Gateway → Bot
```typescript
// Join a meeting
{ type: "joinMeeting", sessionId: "uuid", meetingUrl: "...", botName?: "..." }
// Leave meeting
{ type: "leaveMeeting", sessionId: "uuid" }
// Play audio
{ type: "playAudio", sessionId: "uuid", audio: { format: "mp3", data: "base64..." } }
```
### Bot → Gateway
```typescript
// Transcript
{ type: "transcript", sessionId: "uuid", transcript: { speaker: "...", text: "...", timestamp: "...", isFinal: true } }
// Status
{ type: "status", sessionId: "uuid", status: "joined" | "in_lobby" | "left" | "error", message?: "..." }
```
## Meeting URL Formats
Supports both classic and new (short) URL formats:
```
# Classic format
https://teams.microsoft.com/l/meetup-join/19%3ameeting_xxx/0?context=...
# New format (since 2025)
https://teams.microsoft.com/meet/36438888781520?p=5fGqrujxzewPFjJacW
```
## Deployment
### Azure Container Instance
```bash
# Create resource group
az group create --name rg-teams-bot --location westeurope
# Create container instance
az container create \
--resource-group rg-teams-bot \
--name teams-browser-bot \
--image <your-registry>/teams-browser-bot:latest \
--cpu 2 \
--memory 4 \
--ports 4100 \
--environment-variables \
GATEWAY_WS_URL=wss://gateway-int.poweron-center.net/api/teamsbot/ws \
BOT_NAME="PowerOn AI"
```
## Debugging
- Logs are written to `output/logs/`
- Screenshots (on error) are saved to `output/screenshots/`
- Set `BOT_HEADLESS=false` for local debugging with visible browser
## Based On
- [Recall.ai Microsoft Teams Meeting Bot](https://github.com/recallai/microsoft-teams-meeting-bot)
- [Recall.ai Blog: How to build a Microsoft Teams Bot](https://www.recall.ai/blog/how-to-build-a-microsoft-teams-bot)
| `GATEWAY_WS_URL` | Gateway WebSocket base URL | — |
| `BOT_NAME` | Default bot display name | — |
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | `info` |

321
nyla-marketing.html Normal file
View file

@ -0,0 +1,321 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nyla — Your AI Colleague in Every Meeting</title>
<meta name="description" content="Nyla joins your Microsoft Teams meeting as a real participant. She speaks, chats, answers your questions live, and delivers a full summary when the meeting ends.">
<meta property="og:title" content="Meet Nyla — She joins your meeting as a real colleague.">
<meta property="og:description" content="The first AI assistant that appears as a real person in Microsoft Teams. No bot label. No IT setup. Just share the meeting link.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://poweron.swiss">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--coral:#F25843;
--coral-dark:#D94A37;
--beige:#C7C5B2;
--beige-light:#EFEDE5;
--text:#3A3A3A;
--text-light:#6F7373;
--bg:#F8F9FA;
--white:#fff;
--gray-border:#E0DDD3;
}
html{scroll-behavior:smooth;font-size:16px}
body{font-family:'DM Sans',sans-serif;color:var(--text);background:var(--bg);line-height:1.6;-webkit-font-smoothing:antialiased}
a{color:var(--coral);text-decoration:none}
a:hover{color:var(--coral-dark)}
.container{max-width:960px;margin:0 auto;padding:0 24px}
nav{position:fixed;top:0;left:0;right:0;z-index:100;background:rgba(248,249,250,.88);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--gray-border);padding:14px 0}
nav .container{display:flex;align-items:center;justify-content:space-between}
.logo-img{height:34px}
.nav-cta{display:inline-block;background:var(--coral);color:var(--white);padding:9px 26px;border-radius:30px;font-weight:600;font-size:.85rem;transition:all .2s}
.nav-cta:hover{background:var(--coral-dark);color:var(--white);transform:translateY(-1px)}
section{padding:80px 0}
.section-label{display:inline-block;color:var(--coral);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;margin-bottom:10px}
.section-title{font-size:clamp(1.6rem,3.5vw,2.2rem);font-weight:700;line-height:1.15;letter-spacing:-.02em;margin-bottom:16px}
.section-desc{font-size:.95rem;color:var(--text-light);max-width:540px;line-height:1.6;margin-bottom:40px}
.center{text-align:center}
.center .section-desc{margin-left:auto;margin-right:auto}
/* HERO */
.hero{padding:140px 0 80px;text-align:center;position:relative}
.hero::before{content:'';position:absolute;top:60px;left:50%;transform:translateX(-50%);width:500px;height:500px;background:radial-gradient(circle,rgba(242,88,67,.07) 0%,transparent 70%);pointer-events:none}
.hero-badge{display:inline-block;background:rgba(242,88,67,.1);color:var(--coral);padding:6px 18px;border-radius:30px;font-size:.75rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;margin-bottom:20px}
.hero h1{font-size:clamp(2.2rem,5vw,3.4rem);font-weight:700;line-height:1.1;letter-spacing:-.03em;margin-bottom:16px}
.hero h1 em{font-style:normal;color:var(--coral)}
.hero-sub{font-size:clamp(1rem,2vw,1.15rem);color:var(--text-light);max-width:520px;margin:0 auto 32px;line-height:1.5}
.btn-primary{display:inline-block;background:var(--coral);color:var(--white);padding:14px 40px;border-radius:30px;font-size:1rem;font-weight:600;transition:all .25s;box-shadow:0 4px 20px rgba(242,88,67,.25)}
.btn-primary:hover{background:var(--coral-dark);color:var(--white);transform:translateY(-2px);box-shadow:0 8px 28px rgba(242,88,67,.3)}
/* GAME CHANGER */
.game-changer{background:var(--white)}
.gc-grid{display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-bottom:40px}
.gc-card{padding:32px 28px;border-radius:15px;border:1px solid var(--gray-border)}
.gc-card.old{background:var(--bg)}
.gc-card.new{background:linear-gradient(135deg,rgba(242,88,67,.03),rgba(242,88,67,.09));border-color:rgba(242,88,67,.2)}
.gc-card h3{font-size:1rem;font-weight:600;margin-bottom:16px}
.gc-card.new h3{color:var(--coral)}
.gc-item{display:flex;align-items:flex-start;gap:10px;margin-bottom:10px;font-size:.88rem;line-height:1.4}
.gc-icon{flex-shrink:0;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:.7rem;margin-top:1px}
.gc-card.old .gc-icon{background:rgba(111,115,115,.1);color:var(--text-light)}
.gc-card.new .gc-icon{background:rgba(242,88,67,.15);color:var(--coral)}
.facts{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
.fact{text-align:center;padding:24px 12px;background:var(--white);border:1px solid var(--gray-border);border-radius:12px;transition:all .3s}
.fact:hover{border-color:var(--coral);transform:translateY(-2px)}
.fact-val{font-size:1.3rem;font-weight:700;color:var(--coral);margin-bottom:4px}
.fact-lbl{font-size:.78rem;color:var(--text-light);font-weight:500}
/* FEATURES */
.feat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}
.feat{padding:28px 22px;background:var(--white);border:1px solid var(--gray-border);border-radius:12px;transition:all .3s}
.feat:hover{border-color:var(--coral);transform:translateY(-2px)}
.feat-ico{width:40px;height:40px;background:rgba(242,88,67,.1);border-radius:10px;display:flex;align-items:center;justify-content:center;margin-bottom:14px}
.feat-ico svg{width:20px;height:20px;color:var(--coral)}
.feat h3{font-size:.95rem;font-weight:600;margin-bottom:6px}
.feat p{font-size:.82rem;color:var(--text-light);line-height:1.45}
/* COMPARISON */
.cmp-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
table.cmp{width:100%;border-collapse:separate;border-spacing:0;background:var(--white);border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.05)}
table.cmp th,table.cmp td{padding:14px 14px;text-align:center;font-size:.8rem;border-bottom:1px solid var(--gray-border)}
table.cmp th{background:var(--bg);font-weight:600;font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;color:var(--text-light)}
table.cmp th:first-child,table.cmp td:first-child{text-align:left;padding-left:20px;font-weight:500}
table.cmp tr:last-child td{border-bottom:none}
.col-n{background:rgba(242,88,67,.04)!important;border-left:2px solid var(--coral);border-right:2px solid var(--coral);font-weight:600}
table.cmp thead .col-n{background:rgba(242,88,67,.12)!important;color:var(--coral);font-weight:700;border-top:2px solid var(--coral)}
table.cmp tbody tr:last-child .col-n{border-bottom:2px solid var(--coral)}
.y{color:var(--coral);font-weight:700}
.n{color:#ccc}
.p{color:var(--text-light);font-size:.72rem}
/* FOOTER CTA */
.footer-cta{text-align:center;padding:80px 0 48px}
.footer-cta h2{font-size:clamp(1.6rem,3.5vw,2.4rem);font-weight:700;letter-spacing:-.02em;margin-bottom:12px}
.footer-cta h2 em{font-style:normal;color:var(--coral)}
.footer-cta p{font-size:.95rem;color:var(--text-light);margin-bottom:32px;max-width:400px;margin-left:auto;margin-right:auto}
footer{text-align:center;padding:24px 0;border-top:1px solid var(--gray-border);font-size:.75rem;color:var(--text-light)}
footer .logo-img{height:26px;margin-bottom:6px}
.reveal{opacity:0;transform:translateY(24px);transition:opacity .6s ease,transform .6s ease}
.reveal.visible{opacity:1;transform:translateY(0)}
@media(max-width:768px){
.gc-grid{grid-template-columns:1fr}
.facts{grid-template-columns:1fr 1fr}
.feat-grid{grid-template-columns:1fr}
table.cmp{min-width:580px}
.hero{padding:120px 0 60px}
}
</style>
</head>
<body>
<nav>
<div class="container">
<img class="logo-img" src="data:image/png;base64,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" alt="PowerOn">
<a href="https://poweron.swiss" class="nav-cta" target="_blank" rel="noopener">Learn More</a>
</div>
</nav>
<!-- HERO -->
<section class="hero">
<div class="container">
<div class="hero-badge">New from PowerOn</div>
<h1>Meet <em>Nyla</em></h1>
<p class="hero-sub">She joins your Microsoft Teams meeting as a <strong>real colleague</strong>. She listens. She speaks. She answers your questions&nbsp;&mdash;&nbsp;live.</p>
<a href="https://poweron.swiss" class="btn-primary" target="_blank" rel="noopener">Discover Nyla</a>
</div>
</section>
<!-- GAME CHANGER -->
<section class="game-changer">
<div class="container center">
<div class="section-label reveal">Why this changes everything</div>
<h2 class="section-title reveal">No one in the meeting knows it's AI.</h2>
<p class="section-desc reveal">
Nyla appears as a real person in the participant list. No "Bot" badge. No "Guest" tag. No IT setup at your end. Just share the meeting link.
</p>
<div class="gc-grid reveal">
<div class="gc-card old">
<h3>Other AI meeting tools</h3>
<div class="gc-item"><div class="gc-icon">&times;</div><div>Show up as <strong>"Bot"</strong> or <strong>"Guest"</strong> in the roster</div></div>
<div class="gc-item"><div class="gc-icon">&times;</div><div>Need your IT admin to allow guest access</div></div>
<div class="gc-item"><div class="gc-icon">&times;</div><div>Require app installation or licenses</div></div>
<div class="gc-item"><div class="gc-icon">&times;</div><div>Only transcribe &mdash; can't speak or act</div></div>
</div>
<div class="gc-card new">
<h3>Nyla</h3>
<div class="gc-item"><div class="gc-icon">&check;</div><div>Appears as a <strong>real person</strong> &mdash; no labels</div></div>
<div class="gc-item"><div class="gc-icon">&check;</div><div>Zero setup for you &mdash; no admin, no config</div></div>
<div class="gc-item"><div class="gc-icon">&check;</div><div>Nothing to install. No licenses needed.</div></div>
<div class="gc-item"><div class="gc-icon">&check;</div><div>Speaks, chats, answers questions &mdash; live</div></div>
</div>
</div>
<div class="facts reveal">
<div class="fact"><div class="fact-val">Zero</div><div class="fact-lbl">IT Setup</div></div>
<div class="fact"><div class="fact-val">No</div><div class="fact-lbl">Bot Label</div></div>
<div class="fact"><div class="fact-val">Any</div><div class="fact-lbl">Meeting &amp; Org</div></div>
<div class="fact"><div class="fact-val">Full</div><div class="fact-lbl">Meeting Access</div></div>
</div>
</div>
</section>
<!-- WHAT NYLA CAN DO -->
<section>
<div class="container center">
<div class="section-label reveal">What she can do</div>
<h2 class="section-title reveal">Not a recorder. A participant.</h2>
<p class="section-desc reveal">Nyla listens, speaks, answers, analyzes, and summarizes &mdash; while the meeting is still running.</p>
<div class="feat-grid">
<div class="feat reveal">
<div class="feat-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg></div>
<h3>Speaks in the meeting</h3>
<p>Participants hear Nyla speak &mdash; just like any other colleague.</p>
</div>
<div class="feat reveal">
<div class="feat-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></div>
<h3>Chats in real-time</h3>
<p>Reads and writes in the meeting chat. Voice, text, or both.</p>
</div>
<div class="feat reveal">
<div class="feat-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div>
<h3>Answers questions live</h3>
<p>"Nyla, summarize what we discussed." She responds immediately.</p>
</div>
<div class="feat reveal">
<div class="feat-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div>
<h3>Analyzes continuously</h3>
<p>Real-time AI running throughout the meeting. Not just after.</p>
</div>
<div class="feat reveal">
<div class="feat-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></div>
<h3>Full transcript</h3>
<p>Every word, every speaker, properly attributed. Streamed live.</p>
</div>
<div class="feat reveal">
<div class="feat-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg></div>
<h3>Meeting summary</h3>
<p>Key decisions, action items, topics &mdash; delivered when the meeting ends.</p>
</div>
</div>
</div>
</section>
<!-- COMPARISON -->
<section class="game-changer">
<div class="container center">
<div class="section-label reveal">The comparison</div>
<h2 class="section-title reveal">See how Nyla stacks up.</h2>
<div class="cmp-wrap reveal">
<table class="cmp">
<thead>
<tr>
<th style="min-width:180px">What you get</th>
<th class="col-n">Nyla</th>
<th>Fireflies.ai</th>
<th>Read.ai</th>
<th>Otter.ai</th>
<th>MS Copilot</th>
</tr>
</thead>
<tbody>
<tr>
<td>Appears as a real colleague</td>
<td class="col-n"><span class="y">&check;</span></td>
<td><span class="p">Guest label</span></td>
<td><span class="p">App label</span></td>
<td><span class="p">Bot label</span></td>
<td><span class="p">Native</span></td>
</tr>
<tr>
<td>Zero IT setup at your end</td>
<td class="col-n"><span class="y">&check;</span></td>
<td><span class="n">&times;</span></td>
<td><span class="n">&times;</span></td>
<td><span class="p">Partial</span></td>
<td><span class="n">&times;</span></td>
</tr>
<tr>
<td>Speaks in the meeting</td>
<td class="col-n"><span class="y">&check;</span></td>
<td><span class="y">&check;</span></td>
<td><span class="n">&times;</span></td>
<td><span class="n">&times;</span></td>
<td><span class="n">&times;</span></td>
</tr>
<tr>
<td>Chats in the meeting</td>
<td class="col-n"><span class="y">&check;</span></td>
<td><span class="y">&check;</span></td>
<td><span class="n">&times;</span></td>
<td><span class="n">&times;</span></td>
<td><span class="p">Text only</span></td>
</tr>
<tr>
<td>Answers questions on demand</td>
<td class="col-n"><span class="y">&check;</span></td>
<td><span class="y">&check;</span></td>
<td><span class="n">&times;</span></td>
<td><span class="n">&times;</span></td>
<td><span class="p">Partial</span></td>
</tr>
<tr>
<td>Runs commands in Teams</td>
<td class="col-n"><span class="y">&check;</span></td>
<td><span class="n">&times;</span></td>
<td><span class="n">&times;</span></td>
<td><span class="n">&times;</span></td>
<td><span class="p">Partial</span></td>
</tr>
<tr>
<td>Works with any organization</td>
<td class="col-n"><span class="y">&check;</span></td>
<td><span class="p">Admin needed</span></td>
<td><span class="p">App install</span></td>
<td><span class="p">Partial</span></td>
<td><span class="n">&times;</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- FOOTER CTA -->
<section class="footer-cta">
<div class="container">
<h2>Ready to meet <em>Nyla</em>?</h2>
<p>Your AI colleague. In every meeting. Starting now.</p>
<a href="https://poweron.swiss" class="btn-primary" target="_blank" rel="noopener">Visit poweron.swiss</a>
</div>
</section>
<footer>
<div class="container">
<img class="logo-img" src="data:image/png;base64,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" alt="PowerOn">
<p>&copy; 2026 PowerOn AG. All rights reserved.</p>
</div>
</footer>
<script>
document.addEventListener('DOMContentLoaded',function(){
var r=document.querySelectorAll('.reveal');
if(!('IntersectionObserver' in window)){r.forEach(function(e){e.classList.add('visible')});return}
var o=new IntersectionObserver(function(en){en.forEach(function(e){if(e.isIntersecting){e.target.classList.add('visible');o.unobserve(e.target)}})},{threshold:.12,rootMargin:'0px 0px -30px 0px'});
r.forEach(function(e){o.observe(e)});
});
</script>
</body>
</html>