@brain-ai/teams-bot
v0.0.28
Published
Unified CLI for brain: setup, start, stop, logs, status
Readme
Brain
A unified CLI that turns Microsoft Teams into a personal AI assistant interface, powered by the Claude Agent SDK. One command connects to the shared gateway, another provisions your own — then you chat with Claude directly in Teams with full tool use, persistent sessions, and scheduled tasks.
How It Works
Brain follows a 3-tier WebSocket relay architecture: a shared Cloud Gateway on Azure routes Teams messages over persistent WebSocket connections to each user's local machine, where Claude processes them.
flowchart TB
subgraph teams ["Microsoft Teams"]
user(["User @mentions bot"])
end
subgraph bfs ["Azure Bot Service"]
bf["Bot Framework Service\n(message routing)"]
end
subgraph azure ["Azure App Service (shared)"]
gw["Cloud Gateway (bot-app)\nTeams SDK · WSS relay\nOID routing · offline buffer"]
end
subgraph local ["User's Machine"]
cli["brainbotbeta CLI\nMSAL auth · daemon\ntoken refresh · LLM proxy"]
server["local-server\nClaude Agent SDK\nSQLite · scheduler · MCP tools"]
end
user -- "message" --> bf
bf -- "reply" --> user
bf -- "POST /api/messages" --> gw
gw -- "reply\n(Managed Identity, no secrets)" --> bf
server -- "WSS /connect\n(Entra ID JWT)" --> gw
gw -- "MessageFrame" --> server
server -- "ResponseFrame\nTypingFrame · PushFrame" --> gw
cli -. "spawns + manages" .-> server
cli -. "refreshes tokens\n(relay JWT · Graph token)" .-> serverKey insight: the local-server connects outbound to the Gateway — no port forwarding, no tunnels, works behind any NAT/firewall. When the local-server is offline, the Gateway buffers messages (up to 100/user, 1h TTL) and replays them on reconnect.
Prerequisites
- Node.js >= 22.13.0 (for built-in
node:sqlite) - Azure CLI (
az) — logged in withaz login - Claude Code CLI (
claude) — installed viawinget install Anthropic.ClaudeCodeornpm install -g @anthropic-ai/claude-code - GitHub CLI (
gh) — logged in withgh auth login(needed to download from private repo) - A Microsoft 365 account with Teams
- A GitHub account (for Copilot LLM proxy authentication)
All prerequisites are auto-installed by the install script if missing (via
winget).
Quick Start (End Users)
Install directly from npm:
npm install -g @brain-ai/teams-botThen start the daemon:
brainbotbeta start --gateway wss://brain-bot-beta.azurewebsites.net/connectAlternatively, use the install script which also handles prerequisites and proxy setup. Requires gh CLI installed and authenticated:
Remove-Item "$env:TEMP\*.ps1" -ErrorAction SilentlyContinue; gh release download --repo liuwentong_microsoft/teamsclaw --pattern "*.ps1" --dir $env:TEMP; powershell -ExecutionPolicy Bypass -File "$env:TEMP\install.ps1"The install script will:
- Check and auto-install missing prerequisites (Node.js, gh, Claude Code, Azure CLI)
- Download the latest release from GitHub
- Install
brainbotbetaglobally via npm - Test Claude API access — start LLM proxy if needed
- Start the daemon and connect to the gateway
For upgrades, run the same command again — it auto-detects the old version, stops the daemon, and installs the new one.
Quick Start (Developers)
# Install dependencies and build all four packages (shared + CLI + bot-app + local-server)
npm install
npm run build
# Link the CLI globally so `brainbotbeta` is available in your PATH
npm link
# Connect to an existing shared gateway (first run opens browser for MSAL auth):
brainbotbeta start --gateway wss://brain-bot-beta.azurewebsites.net/connect
# Or self-deploy your own gateway:
brainbotbeta deploy
# Then message the bot in Teams!Starting the daemon later — if auth is cached,
brainbotbeta startsilently refreshes the token and reconnects.
CLI Commands
| Command | Description |
|---------|-------------|
| brainbotbeta start --gateway <url> | First run: discover MSAL config, browser auth, start daemon |
| brainbotbeta start | Subsequent runs: silent MSAL refresh, start daemon |
| brainbotbeta stop | Stop the daemon and clean up state |
| brainbotbeta status | Show gateway URL, PID, uptime, auth status, proxy status |
| brainbotbeta logs | Tail real-time daemon log file |
| brainbotbeta proxy start | GitHub device-flow auth, spawn LLM proxy, configure Claude |
| brainbotbeta proxy stop | Stop proxy, restore Claude settings |
| brainbotbeta proxy status | Show proxy PID, port, health |
| brainbotbeta proxy logs | View proxy logs (-f for follow, --err for stderr) |
| brainbotbeta deploy | Self-deploy: provision Azure resources + register Teams app + auto-start |
| brainbotbeta upgrade | Upgrade to the latest version from npm (restarts daemon if running) |
| brainbotbeta deploy --code-only | Redeploy code only (skip provisioning) |
Project Structure
brain/
├── shared/ # Shared types & utilities (dual CJS/ESM, zero runtime deps)
│ └── src/
│ ├── relay-types.ts # Wire protocol frame types (canonical source)
│ ├── config-types.ts # ClaudeConfig, SkillsConfig, DeepPartial<T>
│ ├── config-defaults.ts # DEFAULT_CLAUDE_CONFIG, DEFAULT_SKILLS_CONFIG
│ ├── deep-merge.ts # deepMerge() — recursive config merge
│ └── expand-tilde.ts # expandTilde() — ~ → homedir in object trees
├── cli/ # CLI tool (runs locally, never deployed)
│ ├── commands/ # start, stop, status, logs, proxy, deploy, upgrade
│ ├── runtime/ # Daemon entry point + child processes
│ │ ├── daemon-entry.ts # Orchestrator: tokens → server → health monitor
│ │ ├── token-refresh.ts # Periodic relay JWT (50min) + Graph token refresh
│ │ ├── server-env.ts # Builds env vars and spawns local-server
│ │ └── llm-proxy/ # LLM proxy (Copilot → Anthropic translation)
│ │ ├── server.ts # HTTP server (localhost:18976)
│ │ ├── converters.ts # Anthropic ↔ OpenAI format conversion
│ │ ├── stream-converter.ts # SSE event translation
│ │ └── token-manager.ts # GitHub → Copilot token exchange
│ ├── lib/ # Shared utilities (MSAL, config, paths)
│ │ ├── msal.ts # MSAL PKCE auth (interactive + silent)
│ │ ├── load-config.ts # BrainConfig read/write
│ │ ├── proxy-lifecycle.ts # LLM proxy start/stop (detached spawn + PID file)
│ │ └── process-utils.ts # isProcessAlive() for daemon health checks
│ └── index.ts # CLI entry point (commander)
├── bot-app/ # Cloud Gateway (deployed to Azure App Service)
│ ├── deploy/ # Self-deploy scripts (Bicep, App Registration, Teams App)
│ └── src/
│ ├── app/app.ts # Teams message handler, image extraction, relay delivery
│ ├── relay/ # WebSocket relay infrastructure
│ │ ├── connect-endpoint.ts # /connect WSS upgrade + /connect/config
│ │ ├── connection-handler.ts # Per-connection lifecycle, heartbeat, frame dispatch
│ │ ├── jwt-validator.ts # Entra ID JWT validation (jose)
│ │ ├── client-registry.ts # OID → connections map (max 5/user)
│ │ ├── reply-context-store.ts# channel_id → ReplyContext (1h TTL, OID-guarded)
│ │ ├── offline-buffer.ts # Message buffer for offline users (100/user, 1h TTL)
│ │ ├── outbound-router.ts # Response/typing/push → Teams delivery
│ │ ├── message-delivery.ts # Inbound message → best connection or buffer
│ │ ├── teams-conversation-store.ts # In-memory conversation refs for push
│ │ ├── client-connection.ts # WebSocket wrapper (serialized sends)
│ │ └── wire-frames.ts # Server-side frame encode/decode
│ ├── config.ts # Environment config
│ └── index.ts # HTTP server entry point
├── local-server/ # Local AI server (runs on your machine)
│ └── src/
│ ├── relay/ # WebSocket client to Gateway
│ │ ├── cloud-channel.ts # WSS client, reconnection, send queue
│ │ ├── html-to-markdown.ts # Teams HTML → Markdown conversion
│ │ └── wire-frames.ts # Client-side frame encode/decode
│ ├── agent/ # Claude Agent SDK integration
│ │ ├── index.ts # query() entry point, session resume, streaming
│ │ ├── context.ts # Builds conversation context from DB history
│ │ ├── hooks.ts # PreCompact hook: archive transcripts before compaction
│ │ └── system-prompt.ts # System prompt loader
│ ├── db/ # SQLite persistence (node:sqlite)
│ │ ├── schema.ts # Tables: messages, sessions, tasks, dirs, images, workspaces
│ │ ├── messages.ts # Message + image CRUD
│ │ ├── sessions.ts # Session persistence
│ │ ├── tasks.ts # Scheduled task CRUD
│ │ ├── state.ts # KV store for router state
│ │ └── directories.ts # Per-session directory CRUD
│ ├── mcp/ # Custom MCP tools for Claude
│ │ ├── push-message.ts # Proactive messaging to Teams
│ │ ├── schedule-task.ts # Schedule recurring/one-shot tasks
│ │ ├── list-tasks.ts # List scheduled tasks
│ │ ├── manage-tasks.ts # Pause, resume, cancel, update tasks
│ │ └── manage-dirs.ts # Add, remove, list per-session directories
│ ├── scheduler/ # Task execution engine
│ │ ├── index.ts # Polling loop (60s, setTimeout chain)
│ │ ├── runner.ts # Execute task → push result to Teams
│ │ └── next-run.ts # Compute next run time (cron/interval/once)
│ ├── index.ts # Message routing, # commands, concurrency pipeline
│ └── graph.ts # Graph API: channels, messages, threads
├── scripts/
│ ├── postinstall.js # Writes package paths to config, installs sub-project deps
│ ├── pack.js # Build distributable .tgz (excludes bot-app)
│ ├── pack-deploy.js # Build deploy-kit .zip for self-host
│ └── install.ps1 # Full Windows installer (auto-installs prerequisites)
├── docs/ # Debug & reference documentation
├── tests/ # Graph API integration tests
├── package.json # Root package: CLI dependencies + build scripts
└── tsconfig.json # Root TypeScript config (CommonJS for CLI)Architecture
3-Tier WebSocket Relay
The system has three independent tiers connected by a WebSocket relay protocol:
Teams User ──@bot──> Bot Framework Service ──POST──> Cloud Gateway (Azure App Service)
▲ │ Route by OID
│ reply (Managed Identity) │
└───────────────────────────┤
WSS <┤ /connect (Entra ID JWT)
└──> WSS
User's local-server (runs locally)- Cloud Gateway (
bot-app/) — Receives Teams messages, routes by user OID over WebSocket to the correct local-server. Deploys once, shared by all users. - Local AI Server (
local-server/) — Runs on the user's machine. Processes messages via Claude Agent SDK, sends replies back through the relay. - CLI (
cli/) — Manages the local lifecycle:brainbotbeta startauthenticates via MSAL, starts the daemon (token refresh + local-server auto-respawn), optionally runs the LLM proxy.
Four Independent Packages
| Package | Runs On | Module System | Target | Purpose | |---------|---------|---------------|--------|---------| | shared | Build-time | Dual CJS/ESM | ESNext | Wire protocol types, config types, pure utilities | | cli | Local machine | CommonJS | ES2022 | CLI commands, daemon, MSAL auth, LLM proxy | | bot-app | Azure App Service | ESM (NodeNext) | ESNext | Teams message routing, WSS relay, offline buffer, push delivery | | local-server | Local machine | ESM (NodeNext) | ESNext | Claude Agent SDK, SQLite, MCP tools, task scheduler |
Isolation rules: bot-app and local-server never import each other — they share types through shared/ via file: dependency. bot-app must NOT depend on @anthropic-ai/claude-agent-sdk. local-server must NOT depend on @microsoft/teams.* or @azure/identity.
WebSocket Protocol
JSON text frames, snake_case fields, discriminated by type. Protocol version: 1.
Server → Client (Gateway → local-server):
| Frame | Purpose |
|-------|---------|
| connected | Handshake confirmation (protocol_version, buffered_count) |
| buffered_start / buffered_end | Marks offline buffer replay boundaries |
| message | User message from Teams (channel_id, sender_id, content, attachments, session_key, ...) |
| ping | Heartbeat (every 45s; client must reply pong within 15s) |
| error | Error notification |
Client → Server (local-server → Gateway):
| Frame | Purpose |
|-------|---------|
| pong | Heartbeat reply |
| response | Final reply to a message (consumes ReplyContext) |
| typing | Typing indicator (keeps ReplyContext alive) |
| push | Proactive message (no ReplyContext needed, routed by session_key or most recent conversation) |
| notification | Intermediate notification |
| chunk | Streaming token chunk (reserved) |
Connection lifecycle:
- Client opens
WSS /connectwithAuthorization: Bearer {jwt} - Gateway validates JWT, extracts OID, sends
connected - If offline buffer has messages:
buffered_start→ N ×message→buffered_end - Steady state: message/response/typing/push exchange
- Heartbeat:
pingevery 45s,pongwithin 15s or disconnect
Message Processing Pipeline
Teams @mention → Bot Framework → Gateway (bot-app)
→ OID extraction, image download, MessageFrame creation
→ Route to local-server via WSS (or buffer if offline)
↓
local-server receives MessageFrame
→ Strip @mentions, HTML → Markdown
→ Persist images to disk, store message in SQLite
→ Process in background:
├─ withConcurrencyLimit (max 5 parallel)
├─ withLock (per-conversation mutex)
├─ withRetry (3 attempts, exponential backoff)
└─ processMessage → Claude Agent SDK
→ Load/resume session, build context, query Claude
→ Stream: typing indicators every 3s
→ Result: sendResponse → Gateway → Teams replyDaemon Lifecycle
When you run brainbotbeta start, the daemon:
- Acquires relay JWT via MSAL (interactive on first run, silent thereafter)
- Acquires Graph API token via Azure CLI
- Writes tokens to
~/.brainbotbeta/auth/(mode0o600) - Spawns the local-server process with
GATEWAY_URL+ token file paths - Starts periodic token refresh (relay JWT + Graph token every 50 min)
- Monitors local-server health (auto-respawn with exponential backoff, max 5 retries)
- Writes state to
~/.brainbotbeta/config/state.json
On brainbotbeta stop: CLI sends SIGTERM to the daemon PID → daemon stops refresh loops, kills local-server, removes state file.
LLM Proxy (Copilot → Anthropic)
An optional local HTTP proxy that lets the Claude Agent SDK use GitHub Copilot as its LLM backend:
Claude Agent SDK → POST /v1/messages (Anthropic format)
→ LLM Proxy (localhost:18976)
→ Convert Anthropic → OpenAI Chat Completions format
→ POST api.githubcopilot.com/chat/completions (with Copilot JWT)
→ Convert OpenAI → Anthropic format
← Return to Claude Agent SDKManaged independently via brainbotbeta proxy start/stop. Uses GitHub OAuth device flow for auth. Runs as a detached process tracked by PID file.
Security
Authentication Chain
Every hop is independently authenticated. No link relies on "being internal" for security.
| Hop | Mechanism |
|-----|-----------|
| Teams → Bot Framework | Bot Framework JWT (validated by Teams SDK); bot authenticates via User-Assigned Managed Identity (no secrets) |
| Bot Framework → Gateway | Channel validation: rejects non-msteams channels to prevent DirectLine OID spoofing |
| Gateway → local-server | Entra ID JWT validated at WebSocket handshake (JWKS, issuer, audience checks via jose) |
| local-server → Gateway | Same JWT (presented at connection time); OID extracted and used for all routing |
| Response routing | ReplyContext OID validation: Gateway only routes responses to the OID that sent the original message |
| Push routing | TeamsConversationStore OID validation: proactive messages only reach conversations owned by the sender's OID |
Key Security Properties
- Zero secrets: MSAL PKCE flow with Entra ID JWTs — no client secrets stored anywhere
- Cross-user isolation: OID-based routing with validation at every hop
- Ephemeral tokens: Relay JWT refreshed every 50 min, Graph token refreshed every 50 min
- File permissions: All token files written with
0o600 - Dev bypass: Triple-guarded (
NODE_ENV=development+RELAY_BYPASS_TOKENnon-empty + exact match)
Azure App Service Hardening
- IP restrictions: Only
AzureBotService+AzureCloudService Tags allowed; default deny - SCM lockdown:
scmIpSecurityRestrictionsDefaultAction: Deny— no public Kudu access - HTTPS only: Enforced at App Service level
- Managed Identity: User-Assigned — no client secrets to rotate
Local Server Features
Claude Agent SDK Integration
The local server processes every message through the Claude Agent SDK with:
- Full tool access: File I/O (Read, Write, Edit), Bash, Glob, Grep, WebSearch, WebFetch, NotebookEdit, and more
- Agent Teams: Sub-agent orchestration for parallel work (Task, TaskOutput, SendMessage, etc.)
- Session persistence: Sessions are stored in SQLite and resumed across server restarts
- Conversation context: History built from stored messages in the database
- Transcript archiving: PreCompact hooks archive transcripts to markdown before context compaction
- Per-workspace isolation: Each conversation gets its own working directory
Custom MCP Tools
| Tool | Description |
|------|-------------|
| push_message | Send proactive messages to any Teams conversation |
| schedule_task | Create scheduled tasks (cron/interval/once, local timezone) |
| list_tasks | View all scheduled tasks for the current conversation |
| pause_task / resume_task / cancel_task / update_task | Full task lifecycle management |
| add_dir / remove_dir / list_dirs | Manage per-session directories for the Claude agent |
Task Scheduler
The scheduler runs a polling loop (60-second interval, setTimeout chain) that:
- Finds due tasks from SQLite
- Executes them through the same Claude Agent SDK pipeline (with concurrency limits)
- Pushes results to Teams via the push mechanism
- Logs execution details to
task_run_logs - Supports cron expressions, fixed intervals (minutes), and one-shot execution
- Tasks can run in conversation context (shared session) or isolated (fresh session each time)
SQLite Database
All persistent data lives in ~/.brainbotbeta/data/brain.db:
| Table | Purpose |
|-------|---------|
| messages | Conversation history (keyed by activity ID) |
| message_images | Image attachments linked to messages |
| sessions | Claude Agent SDK session IDs (for resume) |
| scheduled_tasks | Task definitions with schedule, status, and next run time |
| task_run_logs | Execution history for each task run |
| router_state | KV store for serviceUrl persistence, agent timestamps, default workspace |
| session_directories | Per-session additional directories |
| workspace_overrides | Per-conversation cwd override |
Special Commands
| Command | Description |
|---------|-------------|
| #new [topic] | Create a dedicated Teams channel for conversation isolation |
| #add-dir <path> | Register an additional directory for Claude (takes effect next message) |
| #remove-dir <path> | Unregister a directory |
| #list-dirs | Show all active directories for this session |
| #workspace <path> | Set per-conversation working directory |
| #default-workspace <path> | Set global default working directory |
Memory System (3-Layer)
- Per-conversation (
CLAUDE.mdin workspace dir) — auto-discovered by SDK, seeded on first use - Global (
~/.brainbotbeta/global/CLAUDE.md) — shared across all conversations - Auto-memory — SDK-managed automatic pattern extraction
Plus history.md (auto-generated session summaries) and full transcript archives in conversations/.
Runtime Data
All runtime data lives under ~/.brainbotbeta/:
| Path | Purpose |
|------|---------|
| config/config.json | Gateway URL, MSAL params, team ID, claude/skills config |
| config/state.json | Running daemon state (PID, uptime, auth status, server status) |
| config/proxy-state.json | LLM proxy state (PID, port) |
| auth/relay-token | Entra ID JWT for gateway auth (0o600) |
| auth/graph-token | Graph API access token (0o600) |
| auth/github-token | GitHub PAT for LLM proxy (0o600) |
| auth/token_cache_*.json | MSAL token cache (0o600) |
| data/brain.db | SQLite database |
| global/CLAUDE.md | Global memory shared across conversations |
| global/system-prompt.md | System prompt (user-editable) |
| data/workspace/<key>/ | Per-conversation workspaces |
| logs/ | Daemon + proxy logs (out.log, err.log) |
| deploy/provision.json | Self-deploy output (app name, domain, client IDs) |
Development
Build
# Build everything (shared → CLI → bot-app → local-server)
npm run build
# Watch mode for CLI development
npm run dev
# Build individual packages
cd shared && npm run build
cd bot-app && npm run build
cd local-server && npm run buildTest
# Run local-server tests (vitest)
npm test
# Watch mode
cd local-server && npm run test:watch
# Coverage
cd local-server && npm run test:coveragePackaging
# Build distributable .tgz (excludes bot-app, for end users)
npm run pack:dist
# Build deploy-kit .zip (bot-app + shared, for self-host)
npm run pack:deployModule Systems
| Package | Module | Target | Notes |
|---------|--------|--------|-------|
| shared | Dual CJS/ESM | ESNext | Conditional exports: import → ESM, require → CJS |
| Root / CLI | CommonJS | ES2022 | |
| bot-app | NodeNext (ESM) | ESNext | .js extensions in imports |
| local-server | NodeNext (ESM) | ESNext | .js extensions in imports |
Design Principles
3-tier WebSocket relay: Cloud Gateway is a thin message router — no AI logic, no tunnel dependencies. Local-server connects outbound, works behind any NAT/firewall.
Strict runtime isolation: bot-app and local-server share no code, no dependencies, and never import each other. Types flow through
shared/only.Owner-only by design: This is a personal assistant, not a multi-tenant service. Every message is validated against the owner's Entra OID.
Defense in depth: Every hop is independently authenticated — Managed Identity for Bot Framework, JWT for WebSocket relay, OID validation for routing. Zero secrets stored.
Local-first AI: All AI processing stays on your machine. The cloud Gateway is a thin message router. Your data, tools, and file system remain under your control.
No silent failures: Every error must leave a trace. Empty
catch {}blocks are not permitted.Session continuity: Claude Agent SDK sessions survive server restarts via SQLite persistence. Offline messages are buffered by the Gateway and replayed on reconnect.
Deterministic naming: Azure resource names are derived from the user's alias (
brain-beta-{alias}), making them predictable and idempotent.
Uninstall
# 1. Stop the daemon
brainbotbeta stop
# 2. Stop the LLM proxy (if running)
brainbotbeta proxy stop
# 3. Remove the global CLI link
npm unlink -g brain
# 4. Delete all runtime data
rm -rf ~/.brainbotbeta
# 5. (Optional) Delete Azure resources created by deploy
az group delete --name <your-resource-group> --yes