{"id":"add-teams","name":"add-teams","summary":"チャットSDKを通じてMicrosoft Teamsのチャネル統合を追加しましょう。","body":"# Add Microsoft Teams Channel\n\nAdds Microsoft Teams support via the Chat SDK bridge — interactive chat in team\nchannels, group chats, and direct messages. NanoClaw doesn't ship channels in\ntrunk — this skill copies the Teams adapter in from the `channels` branch.\n\nThe mechanical steps under **Apply** carry `nc:` directive fences: an agent\nreads the prose and applies them, and a parser can apply them deterministically\nfrom the same document. Every directive is idempotent, so the whole skill is\nsafe to re-run; anything a parser can't apply falls back to the prose beside it.\n\nTeams has no \"paste a token\" shortcut — a bot has to exist in Microsoft's cloud\nbefore it can receive a message. The Microsoft Teams CLI collapses that into\none sign-in and one create command: it registers the Entra app, generates the\nclient secret, registers a Teams-managed bot (through the Teams Developer\nPortal — **no Azure subscription needed**), uploads the app package, and hands\nback an install link. The old ~7-step Azure portal walk survives only as a\nfallback in [Alternatives](#alternatives) for tenants where the Developer\nPortal is blocked.\n\n## Apply\n\n### 1. Copy the adapter and its registration test\n\nFetch the `channels` branch and copy the Teams adapter and its registration test\ninto `src/channels/` (overwrite — the branch is canonical):\n\n```nc:copy from-branch:channels\nsrc/channels/teams.ts\nsrc/channels/teams-registration.test.ts\n```\n\n### 2. Register the adapter\n\nAppend the self-registration import to the channel barrel (skipped if the line\nis already present). This one line is the skill's only reach-in into core:\n\n```nc:append to:src/channels/index.ts\nimport './teams.js';\n```\n\n### 3. Install the adapter package\n\nPinned to an exact version — the supply-chain policy rejects ranges and `latest`:\n\n```nc:dep\n@chat-adapter/teams@4.29.0\n```\n\n### 4. Build and validate\n\nBuild first: it guards the typed `createChatSdkBridge(...)` core call and proves\nthe dependency is installed. Then run the one integration test.\n\n```nc:run effect:build\npnpm run build\n```\n```nc:run effect:test\npnpm exec vitest run src/channels/teams-registration.test.ts\n```\n\n`teams-registration.test.ts` imports the real channel barrel and asserts the\nregistry contains `teams`. It goes red if the import line is deleted or drifts,\nif the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the\nimport throws) — so it also covers the dependency from step 3. End-to-end\ndelivery against a real Teams workspace is verified manually once the service\nruns.\n\n## Credentials\n\nThe adapter is installed and registered, but it can't receive a message until a\nbot exists, points at this machine, and is installed into Teams. The Teams CLI\ndoes all of that below.\n\n### Check for existing credentials\n\nRe-running `teams app create` provisions a brand-new app registration and bot\neach time — it never reuses the first one. So the flow starts with a probe:\nwhen `.env` already carries a Teams credential — either key; a partial pair\nmeans a half-finished setup that creating ANOTHER app would only corrupt —\nevery step below (prompts included) is skipped and the flow drops straight\nthrough to [Restart](#restart). To rotate credentials or finish a partial\nconfiguration, see [Troubleshooting](#troubleshooting); if your tunnel URL\nchanged, the fix is `teams app update`, not a re-run (also in Troubleshooting).\n\n```nc:run capture:have_creds\n( grep -q '^TEAMS_APP_ID=.' .env 2>/dev/null || grep -q '^TEAMS_APP_PASSWORD=.' .env 2>/dev/null ) && echo yes || echo no\n```\n\nBefore creating anything, tell the user:\n\n```nc:operator when:have_creds=no\nConfirm you have everything Teams setup needs:\n1. A Microsoft 365 account that can create Entra app registrations and upload custom apps (sideloading) — free personal Teams does NOT qualify; you need a Microsoft 365 Business / EDU / developer tenant.\n2. A way to expose an HTTPS endpoint that forwards to this machine's webhook port 3000 (e.g. a Cloudflare Tunnel, or a reverse-proxied VPS). Start it now if it isn't running — e.g. `cloudflared tunnel --url http://localhost:3000` — the create step needs the URL up front. The next prompt asks for its public base URL: just the https:// origin, no trailing path.\nNote: the bot is created single-tenant (only your own Microsoft 365 tenant can install it) — the right default for a self-hosted assistant. If you need a bot other tenants can install, set it up manually via the Alternatives section of this skill instead.\n```\n\n### Public URL\n\nMicrosoft delivers bot messages to an HTTPS endpoint you control; it has to\nreach this machine's webhook server (port 3000, configurable via\n`WEBHOOK_PORT`) at `/webhook/teams`.\n\n```nc:prompt public_url when:have_creds=no validate:^https:// normalize:rstrip-slash\nPaste your tunnel's public https:// URL — e.g. https://your-tunnel.trycloudflare.com\n```\n\n### App name\n\nOne more choice belongs to the human before anything is created. The name is\nused everywhere at once: the Entra app registration, the bot, and the Teams\napp are all created under it. There is no client-secret name to pick on this\npath — the CLI generates the secret itself (Entra displayName `default`,\n2-year expiry); rotating it later is in [Troubleshooting](#troubleshooting).\n\n```nc:prompt app_name when:have_creds=no validate:^[\\sA-Za-z0-9._-]{1,30}$ normalize:trim\nWhat should the bot be called? One name covers the Entra app registration, the bot, and the Teams app (letters, digits, spaces, . _ -; max 30 characters) — e.g. NanoClaw.\n```\n\n### Install the Teams CLI\n\nInstalled globally with npm — not as a workspace dependency — deliberately:\nthe CLI's credential store (keytar) is a native module whose install script\nmust run to fetch its prebuilt binary, and pnpm's supply-chain policy blocks\ndependency build scripts — a workspace install leaves the sign-in unable to\npersist. The global install matches Microsoft's own instruction and keeps the\nworkspace policy intact. Pinned; re-running is a no-op. (If npm reports\nEACCES here, your global prefix needs root — prefer a user-level Node like\nnvm, or `npm config set prefix ~/.npm-global`.) `--loglevel=error` because\nnpm runs inside a pnpm script here and warns about every pnpm config var it\ninherits — pure noise; real errors still print.\n\n```nc:run effect:external when:have_creds=no\nnpm install -g @microsoft/teams.cli@3.0.2 --loglevel=error\n```\n\nnpm's global bin directory is not reliably on PATH (custom prefixes rarely\nare), so every step below calls the CLI by its absolute path,\n`$(npm prefix -g)/bin/teams` (stderr of the prefix lookup silenced — same\npnpm-config noise as above). Where this document says to run `teams …` by\nhand, use that path too if plain `teams` isn't found.\n\n### Sign in to Microsoft 365\n\nEvery `teams` command is a separate process, so the sign-in must survive into\nthe next one via the CLI's on-disk token cache. A \"libsecret not found —\ntoken cache will be stored unencrypted\" warning here is safe to ignore: the\nCLI falls back to a plaintext cache file that persists fine, and setup signs\nthe session out at the end anyway. The login output may\nalso report \"Azure CLI: not installed\" — informational only; this flow\ncreates a Teams-managed bot precisely so the Azure CLI is never needed (it\nonly matters for `--azure` bots and the manual portal path). The\nstep below verifies persistence by re-reading the session from a fresh\nprocess after login. In an interactive terminal the login opens a browser;\non a headless box (SSH) it prints a device code — open\nmicrosoft.com/devicelogin on any machine and enter it. If this step fails,\nrun `teams login` then `teams status` by hand: status must say logged in, or\nthe cache is not persisting (see Troubleshooting).\n\n```nc:run effect:step when:have_creds=no\n\"$(npm prefix -g 2>/dev/null)/bin/teams\" login && \"$(npm prefix -g 2>/dev/null)/bin/teams\" status --json 2>/dev/null | grep -q '\"loggedIn\": true' && printf '=== NANOCLAW SETUP: TEAMS-LOGIN ===\\nSTATUS: success\\n=== END ===\\n'\n```\n\n### Create the bot\n\nOne command registers the Entra app, generates a client secret (Graph can take\n~30s to see the new app — the CLI retries), registers a Teams-managed bot, and\nuploads the app package to the Teams Developer Portal. It needs the sign-in\nfrom the previous step (`AUTH_REQUIRED` means run that first). The bot is\nalways created single-tenant (`--sign-in-audience myOrg`) — the right default\nfor a self-hosted assistant, applied without asking; for a bot other\nMicrosoft 365 tenants can install, set it up manually per\n[Alternatives](#alternatives).\n\n```nc:run effect:external when:have_creds=no capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,app_tenant_id=.credentials.TENANT_ID,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$\n\"$(npm prefix -g 2>/dev/null)/bin/teams\" app create --name \"{{app_name}}\" --endpoint \"{{public_url}}/webhook/teams\" --sign-in-audience myOrg --json\n```\n\n### Store the credentials\n\nThe adapter reads these from `.env` (set-if-absent — a value you've already\nfilled in is never overwritten). The pairing matters: `SingleTenant` requires\n`TEAMS_APP_TENANT_ID`, and a multi-tenant app must instead set\n`TEAMS_APP_TYPE=MultiTenant` with **no** tenant ID — a mismatch makes the\nadapter authenticate against the wrong authority and every message fails with\na 401 from Bot Framework.\n\n```nc:env-set when:have_creds=no\nTEAMS_APP_ID={{app_id}}\nTEAMS_APP_PASSWORD={{app_password}}\nTEAMS_APP_TENANT_ID={{app_tenant_id}}\nTEAMS_APP_TYPE=SingleTenant\n```\n\n### Set the app icons\n\nThe CLI-created app ships with placeholder icons; this swaps in the NanoClaw\nmascot (the same PNGs the manual-path package bakes into its zip), so the\ninstall dialog below already shows it. Cosmetic — a failure is logged and\nskipped, never blocking setup. Re-runnable any time while signed in to the\nTeams CLI:\n\n```nc:run effect:external when:have_creds=no\n\"$(npm prefix -g 2>/dev/null)/bin/teams\" app update {{teams_app_id}} --color-icon setup/assets/teams/color.png --outline-icon setup/assets/teams/outline.png --json || echo \"Icon update failed — cosmetic only, continuing.\"\n```\n\n### Who owns this bot\n\nThe account signed into the Teams CLI is the account that just created the\nbot — that human is the wiring target this flow suggests. Its identity comes\nfrom the CLI session, so this runs before the sign-out step below:\n\n```nc:run effect:fetch when:have_creds=no capture:owner_upn=.username,owner_aad_id=.userObjectId validate:^.+$\n\"$(npm prefix -g 2>/dev/null)/bin/teams\" status --json 2>/dev/null\n```\n\n### Confirm the wiring target\n\nNothing is wired without a confirmed target, and someone is always wired —\nthere is no skip. The account signed into the Teams CLI is often NOT the\nperson setting up NanoClaw, so a no leads to a clarifying choice: wire the\nlogged-in Teams user after all, or a different Teams user by Microsoft Entra\nobject ID. Identities are shown by sign-in name, never a raw ID:\n\n```nc:operator when:have_creds=no\nDetected the account that created the bot: {{owner_upn}}. Wiring the assistant to it means its first message arrives in that account's Teams DMs.\n```\n\n```nc:prompt wire_owner when:have_creds=no validate:^(yes|no)$ normalize:lower\nWire the assistant to this account?\n```\n\n```nc:operator when:wire_owner=no\nYou're currently logged in to Teams as {{owner_upn}}.\n- To wire the assistant to this logged-in Teams user, choose \"logged-in-account\".\n- To wire a different Teams user, get their Microsoft Entra object ID — found at entra.microsoft.com > Users > (person) > Overview > Object ID, or Teams admin center > Manage users — and choose \"other-account\". Once wired, the assistant messages them first.\n```\n\n```nc:prompt wire_target when:wire_owner=no validate:^(logged-in-account|other-account)$ normalize:lower\nWhich Teams user should the assistant be wired to?\n```\n\n```nc:prompt target_aad_id when:wire_target=other-account validate:^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ normalize:trim\nPaste the Microsoft Entra object ID of the Teams user to wire (a GUID like 00000000-0000-0000-0000-000000000000).\n```\n\nEither choice re-enters the exact same path as a yes above — rebind the\nwiring target and flip the branch, so the link chain below needs no second\ncopy per branch:\n\n```nc:run when:wire_target=other-account capture:owner_aad_id=.aad,wire_owner=.wire validate:^.+$\nprintf '{\"aad\":\"%s\",\"wire\":\"yes\"}' \"{{target_aad_id}}\"\n```\n\n```nc:run when:wire_target=logged-in-account capture:wire_owner validate:^yes$\necho yes\n```\n\n### Install the app in Teams\n\nThe app package is already uploaded — no manifest zip, no manual sideload.\nTell the user:\n\n```nc:operator when:have_creds=no\nInstall the bot into Teams:\n1. Open {{install_link}} — Teams opens with the app's install dialog. Click Add.\n2. If you need the link again later, run: teams app get {{teams_app_id}} --install-link\n3. If Teams refuses with a custom-app-upload error, a tenant admin must enable sideloading: Teams Admin Center > Teams apps > Setup policies > Global > \"Upload custom apps\" = On.\nOnce the app shows up in your Teams sidebar (or app list), continue.\n```\n\n### Link the bot to your account\n\nNothing to do in Teams yet — these are background API calls, and the whole\nchain runs only for a confirmed target from\n[Confirm the wiring target](#confirm-the-wiring-target) (the detected owner\non a yes, or the provided Entra object ID). Same move as\nSlack's `conversations.open` and Discord's `users/@me/channels`:\ncreate the bot↔owner 1:1 conversation proactively with the bot's own\ncredentials, so the assistant messages the human first — nobody has to DM the\nbot to bootstrap it. This only works now that the app is installed (the step\nabove); if Microsoft hasn't finished propagating the install yet, the create\nbelow can fail once — re-running the skill is safe.\n\nFirst a Bot Framework token from the app credentials:\n\n```nc:run effect:fetch when:wire_owner=yes capture:bot_token validate:^eyJ\ncurl -sf -X POST \"https://login.microsoftonline.com/{{app_tenant_id}}/oauth2/v2.0/token\" --data-urlencode \"grant_type=client_credentials\" --data-urlencode \"client_id={{app_id}}\" --data-urlencode \"client_secret={{app_password}}\" --data-urlencode \"scope=https://api.botframework.com/.default\" | jq -er '.access_token'\n```\n\nCreate the 1:1 conversation (the AAD object id from the CLI session is a\nvalid member id; `smba.trafficmanager.net/teams` is the global service URL —\nthe same default the adapter itself uses):\n\n```nc:run effect:fetch when:wire_owner=yes capture:conversation_id validate:^.+$\ncurl -sf -X POST \"https://smba.trafficmanager.net/teams/v3/conversations\" -H \"Authorization: Bearer {{bot_token}}\" -H \"Content-Type: application/json\" -d '{\"bot\":{\"id\":\"28:{{app_id}}\"},\"members\":[{\"id\":\"{{owner_aad_id}}\",\"name\":\"\",\"role\":\"user\"}],\"tenantId\":\"{{app_tenant_id}}\",\"channelData\":{\"tenant\":{\"id\":\"{{app_tenant_id}}\"}},\"isGroup\":false}' | jq -er '.id'\n```\n\nThe adapter identifies inbound senders by their Bot Framework `29:` id, not\nthe AAD id — the owner must be wired under that handle or their replies\nwould not be recognized. The conversation was created with exactly one\nmember (the owner), so its member list is the owner by construction; the\nfilter only guards against channels that list the bot itself (`28:` ids).\n(Don't select by `.aadObjectId` here — the field is not reliably present in\nthis response and its GUID casing varies.)\n\n```nc:run effect:fetch when:wire_owner=yes capture:owner_handle=.id,owner_name=.name validate:^.+$\ncurl -sf \"https://smba.trafficmanager.net/teams/v3/conversations/{{conversation_id}}/members\" -H \"Authorization: Bearer {{bot_token}}\" | jq -er '[.[] | select((.id // \"\") | startswith(\"28:\") | not)][0] | {id, name: (.name // .givenName // \"Teams user\")}'\n```\n\nCompose the platform id exactly as the adapter encodes thread ids\n(`teams:{b64url conversation}:{b64url service url}`):\n\n```nc:run when:wire_owner=yes capture:platform_id validate:^teams:\nnode -e 'const c=process.argv[1];const s=\"https://smba.trafficmanager.net/teams/\";console.log(\"teams:\"+Buffer.from(c).toString(\"base64url\")+\":\"+Buffer.from(s).toString(\"base64url\"))' \"{{conversation_id}}\"\n```\n\n### Sign out of the Teams CLI\n\nThe Microsoft 365 session was only needed to create the bot and identify its\nowner — the running adapter authenticates with the app credentials in\n`.env`, never with your account. On a headless box that session is a\nplaintext token file, which is worth removing unless you plan more `teams …`\ncommands (rotate secret, endpoint update, RSC — each just needs a fresh\n`teams login`, a ~30-second device code):\n\n```nc:prompt signout when:have_creds=no validate:^(yes|no)$ normalize:lower\nSign out of the Teams CLI now? The bot doesn't need this login to run — signing out is recommended on shared or headless boxes, and `teams login` gets you back any time.\n```\n\n```nc:run effect:external when:signout=yes\n\"$(npm prefix -g 2>/dev/null)/bin/teams\" logout\n```\n\n## Restart\n\nRestart the service so it loads the Teams adapter and the credentials you just\nstored:\n\n```nc:run effect:restart\nbash setup/lib/restart.sh\n```\n\n## Finish wiring\n\nOn a fresh create, [Link the bot to your account](#link-the-bot-to-your-account) already resolved\neverything the wire needs — `owner_handle` (the owner's `29:` id) and\n`platform_id` (the bot↔owner DM). The setup wizard wires automatically from\nthose and the welcome message lands in the owner's Teams DMs. Applying this\nskill outside the wizard? Run the same wire yourself:\n\n```bash\npnpm exec tsx scripts/init-first-agent.ts --channel teams --user-id \"teams:<owner_handle>\" --platform-id \"<platform_id>\" --display-name \"<the human's name>\" --agent-name \"<assistant name>\" --role owner\n```\n\n**Fallback (re-runs, or the link step failed):** with credentials already in\n`.env` the resolve steps are skipped, so there is nothing new to wire — the\nfirst run's wiring still stands. If the install was never wired at all, the\nDM-first path always works: DM the bot once (\"hi\" is fine) — the router\nauto-creates the messaging group row in `data/v2.db` from that first inbound\n— then run `/init-first-agent` (or `/manage-channels`) with your coding\nagent.\n\n## Next Steps\n\nIf you're in the middle of `/setup`, return to the setup flow now — it wires\nthe owner automatically from the resolved values. Otherwise wire per\n[Finish wiring](#finish-wiring).\n\n## Channel Info\n\n- **type**: `teams`\n- **terminology**: Teams has \"teams\" containing \"channels.\" The bot can also receive DMs (personal scope) and group chat messages. Channels support threaded replies.\n- **platform-id-format**: `teams:{base64url-conversation-id}:{base64url-service-url}` — auto-generated by the adapter from the first inbound activity, not human-readable. Use the auto-created messaging group for wiring.\n- **how-to-find-id**: Send a message to the bot in the channel or a DM. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group for wiring.\n- **supports-threads**: yes (channels only; DMs and group chats are flat)\n- **typical-use**: Team collaboration with the bot in channels; personal assistant via DMs\n- **default-isolation**: Separate agent group per team. DMs can share an agent group with your main channel for unified personal memory.\n\n## Alternatives\n\n### Multi-tenant bot\n\nThe Credentials flow above always creates a single-tenant bot (only your\nMicrosoft 365 tenant can install it) — the right default for a self-hosted\nassistant, so the skill doesn't ask. For a bot any tenant can install, run\nthe create by hand with `multipleOrgs` and store the matching env pairing —\n`MultiTenant` with **no** tenant ID (the same 401 pairing rule from the\ncredentials step):\n\n```bash\n\"$(npm prefix -g)/bin/teams\" app create --name \"YourBot\" --endpoint \"https://your-domain/webhook/teams\" --sign-in-audience multipleOrgs --json\n```\n\n```bash\nTEAMS_APP_ID=<CLIENT_ID from the output>\nTEAMS_APP_PASSWORD=<CLIENT_SECRET from the output>\nTEAMS_APP_TYPE=MultiTenant\n```\n\nInstall via the `installLink` in the output, then continue from\n[Restart](#restart). If this skill already created a single-tenant app,\nstart over first — see Rotate or recreate credentials in\n[Troubleshooting](#troubleshooting).\n\n### Manual Azure portal path\n\nFor tenants where the Teams Developer Portal is blocked. Unlike the CLI path,\nthe Azure Bot resource in step 3 requires an active **Azure subscription**.\nThis is the classic walk; every value it produces maps onto the same `.env`\nkeys. Ask the human before creating anything: the app registration name,\nsingle vs multi tenant, a client secret description, and (this path only) a\nseparate Azure Bot handle.\n\n1. **App registration**: in https://portal.azure.com, search \"App registrations\"\n   → \"New registration\". Name it (e.g. \"NanoClaw\"); Supported account types:\n   Single tenant (most common for self-host) or Multi tenant. From the Overview\n   page copy the **Application (client) ID** and — single tenant only — the\n   **Directory (tenant) ID**.\n2. **Client secret**: in the app registration, \"Certificates & secrets\" → \"New\n   client secret\" (expires 180 days or longer). **Copy the Value now** — Azure\n   shows it once (the Value column, not the Secret ID).\n3. **Azure Bot resource**: search \"Azure Bot\" → Create. Bot handle: any unique\n   name; Type of App: must match step 1; Creation type: \"Use existing app\n   registration\" with the App ID from step 1. After creating, open the bot →\n   Configuration and set **Messaging endpoint** to\n   `https://your-domain/webhook/teams`, then Apply.\n4. **Enable the Teams channel**: Azure Bot resource → Channels → Microsoft\n   Teams → Accept terms → Apply.\n5. **Store the credentials** in `.env` (the same 401 pairing rule applies —\n   `SingleTenant` needs the tenant ID, `MultiTenant` must omit it):\n\n   ```bash\n   TEAMS_APP_ID=<Application (client) ID>\n   TEAMS_APP_PASSWORD=<client secret Value>\n   TEAMS_APP_TYPE=SingleTenant\n   TEAMS_APP_TENANT_ID=<Directory (tenant) ID>\n   ```\n6. **Build the app package** (manifest + icons, written in-process to\n   `data/teams/teams-app-package.zip` — no `zip` binary needed):\n\n   ```bash\n   pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain\n   ```\n7. **Sideload**: Microsoft Teams → Apps → Manage your apps → Upload an app →\n   \"Upload a custom app\" → select the zip → Add.\n8. Continue from [Restart](#restart).\n\nOr create the bot resource with the Azure CLI instead of the portal:\n\n```bash\naz group create --name nanoclaw-rg --location eastus\naz bot create --resource-group nanoclaw-rg --name nanoclaw-bot --app-type SingleTenant --appid YOUR_APP_ID --tenant-id YOUR_TENANT_ID --endpoint \"https://your-domain/webhook/teams\"\naz bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot\n```\n\n## Optional configuration\n\n### Receive all channel messages (without @-mention)\n\nBy default the bot only receives messages when @-mentioned. With a CLI-created\nbot, grant the resource-specific-consent (RSC) permissions directly — no\nmanifest edit, no re-upload; the app version is bumped automatically:\n\n```bash\nteams app rsc add <teams-app-id> ChannelMessage.Read.Group --type Application\nteams app rsc add <teams-app-id> ChatMessage.Read.Chat --type Application\n```\n\nThen update/reinstall the app in the team so the new permissions get consented.\n(`<teams-app-id>` is the Teams App ID shown in the install step — recover it\nany time with `teams app list`, or find the app at\nhttps://dev.teams.microsoft.com/apps.)\n\nOn the manual path, regenerate the package with RSC baked in and sideload it\nagain (the manifest version is bumped so the upload supersedes the original):\n\n```bash\npnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain --rsc\n```\n\n## Troubleshooting\n\n### \"Upload a custom app\" is missing / sideloading blocked\n\n`teams status` shows whether sideloading is enabled at both tenant\nand user level; the login output prints the same check.\n\n- **Tenant level off**: Teams Admin Center → **Teams apps** → **Setup\n  policies** → **Global** → **Upload custom apps** = On.\n- **\"Enabled for the tenant, but your user policy blocks it\"**: the per-user\n  policy is the blocker — Teams Admin Center → **Users** → find the user →\n  **Policies** → **App setup policy** → assign one with **Upload custom\n  apps** = On. Policy changes can take a while to propagate.\n\nFree personal Teams does not support sideloading at all — use a Microsoft 365\nBusiness / EDU / developer tenant.\n\nThe login step's sideloading probe is **advisory** — policy edits can take\nhours to propagate and the probe has been seen flapping between runs on the\nsame account. The authoritative test is whether the install link's Add\nactually works; only act on the probe if the install itself refuses.\n\n### `teams: command not found`\n\nThe CLI installed fine but npm's global bin directory isn't on your PATH — a\ncommon state with custom npm prefixes. Find it with `npm prefix -g` (the\nbinary is at `<prefix>/bin/teams`), then either add that directory to PATH or\nsymlink the binary somewhere already on it. The skill's own steps are immune —\nthey invoke the absolute path.\n\n### Create fails immediately with `AUTH_REQUIRED` after a successful sign-in\n\nThe sign-in didn't persist: each `teams` command is a separate process, and\nwhen the CLI's credential store can't load it silently falls back to an\nin-memory cache that dies with the login process. Symptom check:\n`teams status` says logged out right after a login succeeded. The known\ncause: the **CLI was installed as a pnpm workspace dependency** — pnpm's\nsupply-chain policy skips dependency build scripts, so keytar (the CLI's\nnative credential store) never gets its binary and the whole store fails to\nload. Use the global npm install this skill performs — and `pnpm uninstall\n@microsoft/teams.cli` if a workspace copy lingers, so `teams` resolves to\nthe global one. (The \"libsecret not found → stored unencrypted\" warning is\nNOT this failure — that fallback persists fine and is safe to ignore.)\n\nAfter fixing, sign in again and confirm `teams status` shows logged in, then\nre-run this skill.\n\n### Bot never receives messages\n\n1. The app is actually installed in Teams — if setup was interrupted before\n   the install step, nothing got installed. Recover the install link:\n   `teams app list` shows the Teams App ID, then\n   `teams app get <teams-app-id> --install-link`.\n2. The tunnel is up and the messaging endpoint matches it — the endpoint must\n   be `https://<your-domain>/webhook/teams`, and your tunnel (e.g.\n   `cloudflared tunnel --url http://localhost:3000`) must be forwarding to\n   this machine's port 3000. Check\n   with `teams app doctor <teams-app-id>` (CLI-created bots) or Azure\n   Bot → **Configuration** (manual path).\n3. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.\n4. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`,\n   `TEAMS_APP_TYPE`).\n\n### Tunnel URL changed\n\nPoint the bot at the new endpoint:\n`teams app update <teams-app-id> --endpoint \"https://new-domain/webhook/teams\"`\n(manual path: Azure Bot → Configuration → Messaging endpoint).\n\n### `Unauthorized` / 401 from Azure Bot Service\n\nEither the credential pairing is wrong, or the secret is dead:\n\n- **Pairing**: `TEAMS_APP_TYPE=SingleTenant` requires `TEAMS_APP_TENANT_ID`;\n  `MultiTenant` must have **no** tenant ID set. A mismatch authenticates\n  against the wrong authority and every send/receive 401s.\n- **Secret**: expired or mispasted. Rotate with\n  `teams app auth secret create <teams-app-id>` (or Azure portal →\n  Certificates & secrets), update `TEAMS_APP_PASSWORD` in `.env`, and restart.\n\n### Rotate or recreate credentials\n\nThe credentials flow skips creation while `.env` has `TEAMS_APP_ID` **or**\n`TEAMS_APP_PASSWORD` — deleting just one line does not make the skill\nregenerate it (that would pair a new app with stale keys). To rotate only the\nsecret, use the 401 section above. To start over completely: delete **all**\n`TEAMS_*` lines from `.env`, optionally delete the old app at\nhttps://dev.teams.microsoft.com/apps (CLI path) or in Azure Portal → App\nregistrations (manual path), then re-run this skill. Re-running\n`teams app create` with old credentials still in `.env` would otherwise create\na second, orphaned app.\n\n### Replies land in the wrong place\n\nA Teams bot's platform ID is derived from the first inbound activity, so wire\nthe messaging group that the router auto-creates after you DM the bot — don't\nguess the platform ID. See **Finish wiring** above.","author":"@nanocoai","ownerProfile":null,"authorContacts":null,"sourceUrl":"https://github.com/nanocoai/nanoclaw/tree/main/.claude/skills/add-teams","license":"MIT","category":"writing","lang":"en","tokens":7097,"stars":0,"calls30d":2,"claimed":false,"visibility":"public","origin":"crawler","version":"0.1.0","createdAt":"2026-08-22","updatedAt":"2026-08-22","files":[{"path":"apply-fixtures.json","size":4930,"sha256":"0a198063428e73d69ac58e9862ac635058148a1ab2c16b86b07ce1ee58d052bc"},{"path":"REMOVE.md","size":1639,"sha256":"13eeb3d9562bcdabc6d21a823d340cd6d1ec84f3952ca09b0b4ffa3699408f1b"}],"requires":{"mcp":[],"tools":[]},"safety":{"flags":[],"scannedAt":"2026-08-22","hasScripts":false,"networkEndpoints":["api.botframework.com","dev.teams.microsoft.com","login.microsoftonline.com","nc.example.com","portal.azure.com","smba.trafficmanager.net","teams.microsoft.com","your-tunnel.trycloudflare.com"]}}