@brain-ai/teams-bot
v0.0.40
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. Pick the agent backend per conversation: the Claude Agent SDK (default — in-process, full MCP / hooks / sub-agents) or the GitHub Copilot CLI (per-turn subprocess). One command connects to the shared gateway, another provisions your own — then you chat 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 · device binding\nofflinebuffer · picker"]
end
subgraph local ["User's Machine(s)"]
cli["brainbotbeta CLI\nMSAL auth · daemon\ntoken refresh · LLM proxy"]
server["local-server\nClaude SDK / Copilot CLI · device-id\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 + x-brain-device-id)" --> 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.
Multi-device routing: each local-server install carries a stable deviceId (UUIDv4 at ~/.brainbotbeta/device-id). The Gateway can route the same OID to different devices for different Teams conversations — bound at first message (silent auto-bind if 1 device online, picker card if 2+). See Multi-Device Routing.
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(required for the defaultclaudebackend) - GitHub CLI (
gh) — logged in withgh auth login(needed to download from private repo) - GitHub Copilot CLI (
copilot) — optional, only required to use thecopilotagent backend (winget install GitHub.CopilotCLIornpm install -g @github/copilot) - A Microsoft 365 account with Teams
- A GitHub account (for Copilot LLM proxy authentication and the optional Copilot agent backend)
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, reconnects, and starts the daemon. To also have it come back after a reboot, runbrainbotbeta start --auto-startonce: that opts in to OS login auto-start and sets up + bakes in the LLM proxy so the bot can still reply after boot.
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 start --auto-start | Opt in to OS login auto-start; also sets up + starts the LLM proxy and bakes --proxy into the boot launcher so the bot still replies after reboot |
| brainbotbeta start --no-auto-start | Deprecated no-op (auto-start is opt-in); still accepted so existing boot launchers keep parsing |
| brainbotbeta start --name <name> | Persist a display name for this device (1–64 chars). Shown in the device picker when you run multiple local-servers under one account. |
| 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 service install | Auto-start brainbotbeta start on login (systemd/launchd/Windows Startup) and start it now |
| brainbotbeta service install --proxy | Same, and also auto-start the LLM proxy on login |
| brainbotbeta service uninstall | Remove auto-start (leaves a running daemon untouched) |
| brainbotbeta service status | Show whether auto-start is installed |
| 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) |
Auto-start on login —
brainbotbeta startregisters an OS launcher by default so the daemon reconnects to the gateway automatically after a reboot. It uses a systemd user unit on Linux, a launchd LaunchAgent on macOS, and a hidden VBScript launcher in the Startup folder on Windows. Usebrainbotbeta service installto manage it explicitly (for example--proxy), or remove it withbrainbotbeta service uninstall.
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/ # Agent backends (Claude SDK + Copilot CLI dispatcher)
│ │ ├── index.ts # processMessage dispatcher + Claude SDK query path (resolveBackend, session resume, streaming)
│ │ ├── copilot-runner.ts # GitHub Copilot CLI driver (spawns `copilot --output-format json`, parses JSONL)
│ │ ├── context.ts # Builds conversation context from DB history
│ │ ├── hooks.ts # PreCompact hook: archive transcripts before compaction (Claude backend only)
│ │ └── system-prompt.ts # NECESSARY + OVERRIDABLE layers, composeSystemPrompt()
│ ├── 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 the configured agent backend (Claude Agent SDK in-process orcopilotCLI spawned per turn), 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 / Copilot CLI dispatcher, 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 by DeviceBindingStore[sessionKey]:
├─ bound + device online → deliver to bound device
├─ bound + device offline → buffer for that deviceId
├─ unbound + 1 device → auto-bind + deliver
├─ unbound + 2+ devices → post Adaptive Card picker (PendingMessageStore holds msg)
└─ unbound + 0 devices → buffer untargeted (drain to first reconnect)
↓
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 → resolveBackend(conversationKey)
├─ claude → @anthropic-ai/claude-agent-sdk query() (in-process, MCP/hooks/sub-agents)
└─ copilot → spawn `copilot --output-format json ...` (per-turn subprocess, JSONL stdout)
→ Typing indicators every 3s while the backend works
→ 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
Multi-Device Routing
You can run local-server on more than one machine under the same Microsoft 365 account (e.g. work laptop + Cloud PC). Each Teams conversation binds to one of those devices; subsequent messages in the same conversation always go to the bound device.
How a Teams conversation gets bound
When the first message of a new conversation arrives:
| Devices online | Behavior |
|---|---|
| 0 | Buffered (💤 queued). When the first device reconnects, the message drains to it and the conversation binds to that device's deviceId |
| 1 | Silent auto-bind to the sole device. No picker. Indistinguishable from single-device use. |
| 2+ | Gateway posts an Adaptive Card listing every online device by name. Original message held in PendingMessageStore. User clicks → binding recorded → held message delivered. |
Subsequent messages in the same conversation always go to the bound device. If the bound device is offline, the message is buffered for that specific deviceId and drained when it reconnects.
Device identity
deviceId: opaque UUIDv4 persisted at~/.brainbotbeta/device-id(mode0o600) on first start. Stable across restarts; survivesbrainbotbeta upgrade. Regenerated if the file is missing (e.g. fresh install or wiped~/.brainbotbeta/).deviceName: human-readable display string sent in the picker. Resolution order:--name <name>passed tobrainbotbeta start(persisted to config)BRAIN_DEVICE_NAMEenv varos.hostname()
Display only — never used for routing.
Switching devices
The only way to (re)bind a conversation to a different device is to start a new Teams conversation with #new — switching mid-conversation would orphan the Claude session, SQLite history, and any open workspace state on the original device.
Legacy clients (older local-server versions)
A local-server build that predates the multi-device feature does not send x-brain-device-id / x-brain-device-name headers on the WSS upgrade. Such connections are accepted (Gateway generates an ephemeral per-connection UUID and marks the connection isLegacy = true), but they are never included in the picker and never bound. Messages are delivered to legacy connections only when no non-legacy device is online — MRU by last pong. Upgrade local-server to participate in routing.
Local Server Features
Agent Backends
The local server drives one of two agent backends per conversation. Both return the same AgentResult, so the rest of the pipeline (typing indicators, sendResponse, push notifications, session persistence in SQLite) is unchanged.
claude(default) —@anthropic-ai/claude-agent-sdkin-process. Full MCP servers, PreCompact hooks, Agent Teams sub-agent orchestration, additional directories, session resume.copilot— Spawnscopilot --output-format json --allow-all -s ...per turn (@github/copilotnpm package, separate binary). Prompt via stdin (avoids Windows arg-length limits); JSONL stdout parsed forassistant.message_delta(buffered into the final reply) andresult(session id persisted for--resumenext turn). Trades the MCP / hooks / sub-agents extension surface for Copilot's fully managed agent loop and Copilot-side model routing.
Resolution order (per call): per-conversation router_state['backend:<key>'] → global router_state['global:defaultBackend'] → config.claude.backend → fallback 'claude'. Switch with #backend (per-conversation) or #default-backend (global). Switching backends clears the persisted session id because Claude and Copilot session IDs are not interchangeable.
Binary resolution (Copilot only): COPILOT_CLI_PATH env var wins; otherwise PATH-based lookup, cached for process lifetime. 15-minute per-turn timeout.
Claude Agent SDK Capabilities (claude backend)
When backend = 'claude', the agent has access to:
- 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
Copilot CLI Capabilities (copilot backend)
When backend = 'copilot', the agent has access to Copilot's built-in tool set (file edits, shell, web fetch, the bundled github-mcp-server, etc.) and Copilot-side model routing. The custom MCP servers, PreCompact hooks, and Agent Teams sub-agents from the Claude backend are not available in this mode. Image attachments are forwarded via --attachment <path>; session resume uses --resume <id> and the result event's session id, persisted in the same sessions table.
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 | Agent session IDs for resume (one row per conversation; stores either a Claude SDK session ID or a Copilot CLI session ID depending on the active backend at write time) |
| 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 |
| #list-sessions [<page>\|next\|prev] | List Claude sessions in this workspace (40/page). If AgentLink is running locally, each row also gets an [open] link into AgentLink's web UI for that exact session. |
| #session <n> | Resume the Nth session from the most recent #list-sessions page |
| #system-prompt [prompt] | Override the per-conversation system prompt (no arg clears). Overrides only the style/guidelines layer; identity and tool knowledge are preserved. |
| #default-system-prompt <prompt> | Replace the global default overridable layer |
| #reset-system-prompt | Restore the built-in default overridable layer |
| #backend [claude\|copilot\|reset] | Show / switch the agent backend for this conversation (reset clears the override). Switching clears the persisted session id. |
| #default-backend [claude\|copilot] | Show / set / clear the global default agent backend (no arg clears it; falls back to config.claude.backend, default claude). |
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/.
BrainCore Integration (Local HTTP API)
The local-server exposes a REST endpoint for external tools (e.g. BrainCore) to proactively push messages to Teams channels.
Discovery: on startup, the local-server writes ~/.brainbotbeta/local-server.json:
{
"host": "127.0.0.1",
"port": 3978,
"pid": 12345,
"tokenPath": "C:\\Users\\you\\.brainbotbeta\\api-token"
}Usage:
- Read
~/.brainbotbeta/local-server.jsonto discover host, port, and token path - Read the file at
tokenPathto get the bearer token - Call the API:
curl -X POST http://127.0.0.1:3978/api/push \
-H "Authorization: Bearer $(cat ~/.brainbotbeta/api-token)" \
-H "Content-Type: application/json" \
-d '{
"content": "Hello from BrainCore!",
"targetChannelId": "19:[email protected]",
"conversationKey": "cloud_teams:oid:...",
"threadId": "1781062301786",
"sessionId": "uuid",
"workspace": "D:\\my-project",
"format": "markdown"
}'Response: 202 Accepted — message is queued in the send buffer, not confirmed delivered.
Security (3 layers):
- Loopback-only: rejects non-
127.0.0.1/::1requests (403) - Bearer token: auto-generated at
~/.brainbotbeta/api-token(mode0600), required viaAuthorizationheader (401) - Origin check: rejects requests with an
Originheader to block browser CSRF (403)
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) |
| api-token | Bearer token for local HTTP API (0o600, auto-generated) |
| local-server.json | Discovery file for BrainCore: host, port, pid, tokenPath (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: Both agent backends (Claude SDK and Copilot CLI) persist session IDs to SQLite so conversations survive server restarts. 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