From 673b93346060bc8caf3b53240e7b866648b32c9a Mon Sep 17 00:00:00 2001 From: Arnold De La Vega Date: Sun, 22 Mar 2026 12:00:01 -0700 Subject: [PATCH 1/9] deps: add botbuilder-integration-aiohttp for Teams channel Co-Authored-By: Claude Opus 4.6 (1M context) --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index faf93d0..1aad985 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,5 +9,8 @@ pyyaml>=6.0 # Telegram (install if using Telegram channel) # aiohttp is already required above +# Teams (install if using Teams channel) +botbuilder-integration-aiohttp>=4.14.5 + # Remote deployment (install if using /setup-remote-project or /setup-remote-workspace) requests>=2.31 From c036850cf376a0fe19d62d92c367121d1a6694eb Mon Sep 17 00:00:00 2001 From: Arnold De La Vega Date: Sun, 22 Mar 2026 12:00:42 -0700 Subject: [PATCH 2/9] feat: add Teams channel adapter using Bot Framework SDK Co-Authored-By: Claude Opus 4.6 (1M context) --- orchestrator/channel/teams.py | 184 ++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 orchestrator/channel/teams.py diff --git a/orchestrator/channel/teams.py b/orchestrator/channel/teams.py new file mode 100644 index 0000000..9cf6aa0 --- /dev/null +++ b/orchestrator/channel/teams.py @@ -0,0 +1,184 @@ +"""Microsoft Teams channel adapter: Bot Framework webhook receive + async reply. + +Uses botbuilder-integration-aiohttp to handle incoming Activities from Azure Bot +Service. Inherits BaseChannel for shared session management, confirm/cancel flow, +and message splitting. +""" + +from __future__ import annotations + +import asyncio +import logging +import sys +import traceback +from pathlib import Path +from typing import Any, TYPE_CHECKING + +from aiohttp import web +from botbuilder.core import TurnContext, MessageFactory +from botbuilder.core.integration import aiohttp_error_middleware +from botbuilder.core.teams import TeamsActivityHandler +from botbuilder.integration.aiohttp import CloudAdapter, ConfigurationBotFrameworkAuthentication +from botbuilder.schema import Activity + +from orchestrator import ARCHIVE_PATH +from orchestrator.channel.base import BaseChannel, load_credential_file, split_message + +if TYPE_CHECKING: + from orchestrator.server import ConfirmGate + +logger = logging.getLogger(__name__) + +CREDENTIAL_PATH = ARCHIVE_PATH / "teams" / "credentials" +DEFAULT_PORT = 3978 + + +def load_credentials(path: Path | None = None) -> dict[str, str]: + p = path or CREDENTIAL_PATH + return load_credential_file(p) + + +class _BotFrameworkConfig: + """Config object that ConfigurationBotFrameworkAuthentication reads from.""" + + def __init__(self, app_id: str, app_password: str, app_type: str = "MultiTenant"): + self.APP_ID = app_id + self.APP_PASSWORD = app_password + self.APP_TYPE = app_type + self.APP_TENANTID = "" + + +class TeamsChannel(BaseChannel): + """Teams channel: receives messages via Bot Framework webhook, sends via Bot Connector. + + Shares BaseChannel's session state machine, confirm/cancel flow, and + message splitting. Only the transport layer (Bot Framework SDK) is + Teams-specific. + """ + + channel_name = "teams" + + def __init__(self, confirm_gate: ConfirmGate, port: int = DEFAULT_PORT) -> None: + super().__init__(confirm_gate) + creds = load_credentials() + self._app_id = creds["app_id"] + self._port = port + + config = _BotFrameworkConfig( + app_id=creds["app_id"], + app_password=creds["app_password"], + app_type=creds.get("app_type", "MultiTenant"), + ) + self._adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config)) + self._adapter.on_turn_error = self._on_turn_error + + allowed = creds.get("allowed_users", "") + self._allowed_users: set[str] = set() + if allowed: + self._allowed_users = {u.strip() for u in allowed.split(",") if u.strip()} + + # Store conversation references for async replies, keyed by conversation ID + self._conv_refs: dict[str, Any] = {} + + self._runner: web.AppRunner | None = None + self._bot = _TeamsBot(self) + + # -- Transport: send ------------------------------------------------------- + + async def _send(self, callback_info: Any, text: str) -> None: + """BaseChannel calls this to deliver messages. We split + send via Bot Connector.""" + conv_id = callback_info.get("conversation_id", "") + conv_ref = self._conv_refs.get(conv_id) + if not conv_ref: + logger.error("No conversation reference for %s, cannot send reply", conv_id) + return + + chunks = split_message(text, max_len=4096) + for chunk in chunks: + await self._adapter.continue_conversation( + conv_ref, + lambda turn_ctx, c=chunk: turn_ctx.send_activity(MessageFactory.text(c)), + self._app_id, + ) + + # -- Incoming message handling --------------------------------------------- + + async def _on_teams_message(self, turn_context: TurnContext) -> None: + """Called by the inner _TeamsBot when an @mention message arrives.""" + activity = turn_context.activity + + # Strip @mention + TurnContext.remove_recipient_mention(activity) + text = (activity.text or "").strip() + if not text: + return + + user_id = activity.from_property.id if activity.from_property else "" + user_name = activity.from_property.name if activity.from_property else "" + + if self._allowed_users: + if user_id not in self._allowed_users and user_name not in self._allowed_users: + logger.warning("Teams: unauthorized user %s (%s)", user_name, user_id) + return + + # Save conversation reference for async replies + conv_ref = TurnContext.get_conversation_reference(activity) + conv_id = activity.conversation.id if activity.conversation else "" + self._conv_refs[conv_id] = conv_ref + + callback_info = { + "conversation_id": conv_id, + "user_id": user_id, + "user_name": user_name, + } + + logger.info("Teams from %s (conv %s): %s", user_name or user_id, conv_id[:20], text[:100]) + await self._handle_text(text, conv_id, callback_info) + + # -- Error handler --------------------------------------------------------- + + async def _on_turn_error(self, turn_context: TurnContext, error: Exception) -> None: + logger.error("Teams bot turn error: %s", error) + traceback.print_exc(file=sys.stderr) + try: + await turn_context.send_activity("An error occurred processing your request.") + except Exception: + logger.exception("Failed to send error message to Teams") + + # -- Lifecycle ------------------------------------------------------------- + + async def start(self) -> None: + app = web.Application(middlewares=[aiohttp_error_middleware]) + app.router.add_post("/api/messages", self._handle_webhook) + app.router.add_get("/health", self._handle_health) + + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, "0.0.0.0", self._port) + await site.start() + logger.info("Teams channel started on port %d", self._port) + + async def stop(self) -> None: + if self._runner: + await self._runner.cleanup() + self._runner = None + logger.info("Teams channel stopped.") + + # -- Webhook endpoint ------------------------------------------------------ + + async def _handle_webhook(self, request: web.Request) -> web.Response: + return await self._adapter.process(request, self._bot) + + async def _handle_health(self, request: web.Request) -> web.Response: + return web.json_response({"status": "ok", "channel": "teams", "port": self._port}) + + +class _TeamsBot(TeamsActivityHandler): + """Inner bot class that delegates to TeamsChannel for message handling.""" + + def __init__(self, channel: TeamsChannel) -> None: + super().__init__() + self._channel = channel + + async def on_message_activity(self, turn_context: TurnContext) -> None: + await self._channel._on_teams_message(turn_context) From 764f0d6798ec49c1760750f8e2050206c828390a Mon Sep 17 00:00:00 2001 From: Arnold De La Vega Date: Sun, 22 Mar 2026 12:00:55 -0700 Subject: [PATCH 3/9] feat: wire Teams channel into orchestrator main Co-Authored-By: Claude Opus 4.6 (1M context) --- orchestrator/main.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/orchestrator/main.py b/orchestrator/main.py index b3dc00e..90cddc1 100644 --- a/orchestrator/main.py +++ b/orchestrator/main.py @@ -34,6 +34,14 @@ async def main() -> None: tasks.append(asyncio.create_task(tg_ch.start())) logger.info(" Telegram: Long Polling") + if channels_config.get("teams", {}).get("enabled"): + from orchestrator.channel.teams import TeamsChannel + teams_port = channels_config.get("teams", {}).get("port", 3978) + teams_ch = TeamsChannel(confirm_gate, port=teams_port) + register_channel("teams", teams_ch) + tasks.append(asyncio.create_task(teams_ch.start())) + logger.info(" Teams: Bot Framework webhook on port %d", teams_port) + loop = asyncio.get_running_loop() stop_event = asyncio.Event() for sig in (signal.SIGINT, signal.SIGTERM): From a5a20654337580c33205045099846174f7309324 Mon Sep 17 00:00:00 2001 From: Arnold De La Vega Date: Sun, 22 Mar 2026 12:03:02 -0700 Subject: [PATCH 4/9] config: add Teams channel section to orchestrator.yaml Co-Authored-By: Claude Opus 4.6 (1M context) --- orchestrator.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/orchestrator.yaml b/orchestrator.yaml index dde10fe..4060019 100644 --- a/orchestrator.yaml +++ b/orchestrator.yaml @@ -13,6 +13,9 @@ channels: enabled: false telegram: enabled: false + teams: + enabled: false + port: 3978 # Remote workspaces (populated by /setup-remote-project and /setup-remote-workspace) # Each entry maps a workspace to a remote listener From 8f8ceb3add6d1b46fa86f7f86cd7ff8be35c4f57 Mon Sep 17 00:00:00 2001 From: Arnold De La Vega Date: Sun, 22 Mar 2026 12:03:47 -0700 Subject: [PATCH 5/9] feat: add /connect-teams setup skill Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/connect-teams/SKILL.md | 99 +++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 skills/connect-teams/SKILL.md diff --git a/skills/connect-teams/SKILL.md b/skills/connect-teams/SKILL.md new file mode 100644 index 0000000..867f536 --- /dev/null +++ b/skills/connect-teams/SKILL.md @@ -0,0 +1,99 @@ +--- +name: connect-teams +description: "Connect Microsoft Teams to an existing Orchestrator. Guides through Azure Bot registration, credential input, webhook configuration, and connection test. Run with /connect-teams. Use for requests like 'connect teams', 'add teams channel'." +--- + +# Connect Teams Channel + +Connects a Microsoft Teams channel to an already-installed Orchestrator. + +## Prerequisites + +- `orchestrator/` directory must exist in the current folder +- `orchestrator.yaml` must exist +- A publicly reachable HTTPS URL for the bot webhook endpoint + +## Flow + +### Step 1: Verify Orchestrator + +1. Check `orchestrator.yaml` exists in current directory +2. Load config to find ARCHIVE_PATH +3. If not found -> "Please run /setup-orchestrator first" + +### Step 2: Azure Bot Registration Guide + +If no credentials found, show the user: + + Azure Bot Setup Guide: + 1. Go to https://portal.azure.com -> Create a resource -> "Azure Bot" + 2. Fill in: + - Bot handle: choose a unique name + - Subscription & Resource Group: select yours + - Pricing: F0 (free) is fine for testing + - Type of App: Multi Tenant + - Creation type: "Create new Microsoft App ID" + 3. After creation, go to the Bot resource + 4. Settings -> Configuration: + - Messaging endpoint: https://YOUR-PUBLIC-HOST:3978/api/messages + - (You need a public HTTPS URL — use ngrok, Cloudflare Tunnel, or a reverse proxy) + 5. Settings -> Configuration -> "Manage Password" (next to Microsoft App ID) + - Click "New client secret", copy the Value (this is your app_password) + - Copy the Application (client) ID (this is your app_id) + 6. Channels -> Microsoft Teams -> Save (enables the Teams channel) + 7. Go to https://teams.microsoft.com -> Apps -> search for your bot name -> Add to a team + +### Step 3: Collect Credentials + + app_id : (Application/client ID from Azure) + app_password : (Client secret value) + app_type : MultiTenant (or SingleTenant if your org requires it) + allowed_users : (optional, comma-separated Teams user IDs or display names) + +### Step 4: Save & Configure + +1. Create ARCHIVE_PATH/teams/credentials with the collected values +2. Update orchestrator.yaml: set channels.teams.enabled = true +3. Optionally set channels.teams.port (default 3978) +4. Install dependencies: `pip install botbuilder-integration-aiohttp>=4.14.5` + +### Step 5: Networking Check + +Before starting, confirm the webhook URL is reachable: + + # If using ngrok for development: + ngrok http 3978 + + # If using Cloudflare Tunnel: + cloudflared tunnel --url http://localhost:3978 + + # The messaging endpoint in Azure Bot config must match: + # https://YOUR-DOMAIN/api/messages + +### Step 6: Test + +1. Restart orchestrator (or start it): ./start-orchestrator.sh --fg +2. Check logs for "Teams channel started on port 3978" +3. In Microsoft Teams, go to a channel where the bot is added +4. @mention the bot with a test message, e.g.: @OrchestratorBot hello +5. Verify the confirm/cancel flow works + +If no response: +- Check orchestrator logs for errors +- Verify the messaging endpoint URL in Azure Bot settings +- Verify the HTTPS tunnel is running +- Verify the bot is added to the Teams channel + +## Credential File Format + + app_id : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + app_password : your-client-secret-value + app_type : MultiTenant + allowed_users : User One, User Two + +## Rules + +- If orchestrator not installed, redirect to /setup-orchestrator +- Never overwrite existing credentials without confirmation +- The webhook endpoint MUST be HTTPS — Teams/Bot Framework will not send to HTTP +- If allowed_users is empty, all users in channels where the bot is added can interact From eb7cb67344b68aa3cb4107d3e22d6d9f6c4ce5a9 Mon Sep 17 00:00:00 2001 From: Arnold De La Vega Date: Sun, 22 Mar 2026 12:04:22 -0700 Subject: [PATCH 6/9] feat: add Teams option to setup-orchestrator skill Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/setup-orchestrator/SKILL.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/skills/setup-orchestrator/SKILL.md b/skills/setup-orchestrator/SKILL.md index ca37005..6d015c4 100644 --- a/skills/setup-orchestrator/SKILL.md +++ b/skills/setup-orchestrator/SKILL.md @@ -1,6 +1,6 @@ --- name: setup-orchestrator -description: "Full Claude-Code-Tunnels setup. Installs the Project Orchestrator in the current directory, configures environment, discovers workspaces, connects Slack/Telegram channels, and tests the connection. Run with /setup-orchestrator. Use for requests like 'install orchestrator', 'setup PO', 'setup orchestrator'." +description: "Full Claude-Code-Tunnels setup. Installs the Project Orchestrator in the current directory, configures environment, discovers workspaces, connects Slack/Telegram/Teams channels, and tests the connection. Run with /setup-orchestrator. Use for requests like 'install orchestrator', 'setup PO', 'setup orchestrator'." --- # Claude-Code-Tunnels Setup @@ -91,7 +91,7 @@ Ask the user for ALL of the following at once: 2. ARCHIVE_PATH: Credential storage directory (default: PROJECT_ROOT/ARCHIVE) -3. Channels to enable: slack / telegram / multiple (required — must ask) +3. Channels to enable: slack / telegram / teams / multiple (required — must ask) ``` If `$ARGUMENTS` provides PROJECT_ROOT, use it without asking. @@ -130,6 +130,9 @@ channels: enabled: true/false telegram: enabled: true/false + teams: + enabled: true/false + port: 3978 remote_workspaces: [] ``` @@ -159,6 +162,7 @@ Use the confirmed `PIP_CMD`: $PIP_CMD install claude-agent-sdk aiohttp pyyaml $PIP_CMD install slack-bolt slack-sdk # if Slack # Telegram uses aiohttp (already installed) +$PIP_CMD install botbuilder-integration-aiohttp # if Teams ``` If installation fails, show the exact error and ask the user: @@ -186,6 +190,13 @@ If installation fails, show the exact error and ask the user: 3. Collect: bot_token, optionally allowed_users (comma-separated) 4. Create credential file +#### Teams +1. Check if ARCHIVE_PATH/teams/credentials exists +2. If not -> show Azure Bot registration guide (same as /connect-teams Step 2) +3. Collect: app_id, app_password, app_type, optionally allowed_users +4. Create credential file +5. Remind user they need a public HTTPS URL for the messaging endpoint + ### Phase 8: Test & Finish Start orchestrator and test: From 6d3b72a1d16da75dde74bfe25db5fbfe9ee9afe8 Mon Sep 17 00:00:00 2001 From: Arnold De La Vega Date: Sun, 22 Mar 2026 13:43:28 -0700 Subject: [PATCH 7/9] fix: support SingleTenant bot type with tenant ID in credentials Co-Authored-By: Claude Opus 4.6 (1M context) --- orchestrator/channel/teams.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/orchestrator/channel/teams.py b/orchestrator/channel/teams.py index 9cf6aa0..8caaba4 100644 --- a/orchestrator/channel/teams.py +++ b/orchestrator/channel/teams.py @@ -41,11 +41,11 @@ def load_credentials(path: Path | None = None) -> dict[str, str]: class _BotFrameworkConfig: """Config object that ConfigurationBotFrameworkAuthentication reads from.""" - def __init__(self, app_id: str, app_password: str, app_type: str = "MultiTenant"): + def __init__(self, app_id: str, app_password: str, app_type: str = "MultiTenant", tenant_id: str = ""): self.APP_ID = app_id self.APP_PASSWORD = app_password self.APP_TYPE = app_type - self.APP_TENANTID = "" + self.APP_TENANTID = tenant_id class TeamsChannel(BaseChannel): @@ -68,6 +68,7 @@ def __init__(self, confirm_gate: ConfirmGate, port: int = DEFAULT_PORT) -> None: app_id=creds["app_id"], app_password=creds["app_password"], app_type=creds.get("app_type", "MultiTenant"), + tenant_id=creds.get("app_tenant_id", ""), ) self._adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config)) self._adapter.on_turn_error = self._on_turn_error From 7360955ab099d989adbc0a18bff69827fb712ce8 Mon Sep 17 00:00:00 2001 From: Arnold De La Vega Date: Sun, 22 Mar 2026 14:25:33 -0700 Subject: [PATCH 8/9] feat: auto-confirm requests and load user-level skills Skip the confirm/cancel prompt - execute requests immediately. Add user setting sources so spawned sessions have access to installed plugins, skills, and MCP servers. Co-Authored-By: Claude Opus 4.6 (1M context) --- orchestrator/channel/base.py | 13 ++++++------- orchestrator/direct_handler.py | 4 ++-- orchestrator/executor.py | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/orchestrator/channel/base.py b/orchestrator/channel/base.py index 744a3be..8d77baf 100644 --- a/orchestrator/channel/base.py +++ b/orchestrator/channel/base.py @@ -135,15 +135,14 @@ async def _handle_text( callback_info=callback_info, raw_message=user_text, ) - session.pending_request_id = request_id - session.state = SessionState.PENDING_CONFIRM - confirm_msg = ( - f"[{request_id}] I understood your request as:\n" - f"> {user_text}\n\n" - f'Reply "yes" to proceed, "cancel" to abort.' + # Auto-confirm: skip the yes/no prompt and execute immediately + session.pending_request_id = None + session.state = SessionState.EXECUTING + await self._send_and_record( + session, callback_info, f"`{request_id}` Starting work..." ) - await self._send_and_record(session, callback_info, confirm_msg) + await self._do_confirm(session, request_id, callback_info) async def _do_confirm( self, diff --git a/orchestrator/direct_handler.py b/orchestrator/direct_handler.py index 93b9050..226cb10 100644 --- a/orchestrator/direct_handler.py +++ b/orchestrator/direct_handler.py @@ -22,8 +22,8 @@ async def handle_direct_request(user_message: str) -> str: stderr_lines: list[str] = [] options = ClaudeAgentOptions( cwd=str(BASE), system_prompt=DIRECT_HANDLER_SYSTEM_PROMPT, - allowed_tools=["Read","Glob","Grep","Bash","WebFetch","WebSearch"], - max_turns=30, setting_sources=["project"], + allowed_tools=["Read","Glob","Grep","Bash","WebFetch","WebSearch","Skill"], + max_turns=30, setting_sources=["project", "user"], permission_mode="bypassPermissions", model="sonnet", stderr=lambda line: stderr_lines.append(line), ) diff --git a/orchestrator/executor.py b/orchestrator/executor.py index 708a81d..3359ce7 100644 --- a/orchestrator/executor.py +++ b/orchestrator/executor.py @@ -44,7 +44,7 @@ async def run_workspace(project: str, workspace: str, task: str, options = ClaudeAgentOptions( cwd=str(cwd), max_turns=100, allowed_tools=["Read","Write","Edit","Bash","Glob","Grep","Agent","WebFetch","WebSearch","TodoWrite","NotebookEdit","Skill"], - setting_sources=["project"], permission_mode="bypassPermissions", + setting_sources=["project", "user"], permission_mode="bypassPermissions", ) collected_texts: list[str] = [] From e9735f31cdf1092b34993e1fbd0cf3349660f3ab Mon Sep 17 00:00:00 2001 From: Arnold De La Vega Date: Sun, 22 Mar 2026 14:26:11 -0700 Subject: [PATCH 9/9] feat: update plugin metadata for fork with Teams support Add root marketplace.json, update plugin.json to point to arnoldadlv fork, bump version to 1.1.0, add Teams to keywords. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude-plugin/plugin.json | 12 ++++++------ marketplace.json | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 marketplace.json diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 35c9f1d..44f22fc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,12 +1,12 @@ { "name": "claude-tunnels", - "description": "Turn any folder of projects into an AI-orchestrated workspace — route tasks from Slack or Telegram to the right project, execute in dependency-aware phases, and get structured results back.", - "version": "1.0.0", + "description": "AI-orchestrated multi-project workspace with Slack, Telegram, and Teams integration.", + "version": "1.1.0", "author": { - "name": "Claude Tunnels" + "name": "Arnold De La Vega" }, - "homepage": "https://github.com/matteblack9/claude-code-tunnels", - "repository": "https://github.com/matteblack9/claude-code-tunnels", + "homepage": "https://github.com/arnoldadlv/claude-code-tunnels", + "repository": "https://github.com/arnoldadlv/claude-code-tunnels", "license": "MIT", - "keywords": ["orchestrator", "slack", "telegram", "multi-project", "automation", "agent"] + "keywords": ["orchestrator", "slack", "telegram", "teams", "multi-project", "automation", "agent"] } diff --git a/marketplace.json b/marketplace.json new file mode 100644 index 0000000..d912ea8 --- /dev/null +++ b/marketplace.json @@ -0,0 +1,29 @@ +{ + "name": "claude-tunnels", + "owner": { + "name": "Arnold De La Vega" + }, + "metadata": { + "description": "AI-orchestrated multi-project workspace with Slack, Telegram, and Teams integration.", + "version": "1.1.0" + }, + "plugins": [ + { + "name": "claude-tunnels", + "source": { + "source": "github", + "repo": "arnoldadlv/claude-code-tunnels" + }, + "description": "Route tasks from Slack, Telegram, or Teams to the right project, execute in dependency-aware phases, and get structured results back.", + "version": "1.1.0", + "author": { + "name": "Arnold De La Vega" + }, + "homepage": "https://github.com/arnoldadlv/claude-code-tunnels", + "repository": "https://github.com/arnoldadlv/claude-code-tunnels", + "license": "MIT", + "keywords": ["orchestrator", "slack", "telegram", "teams", "multi-project", "automation", "agent"], + "category": "productivity" + } + ] +}