# Microsoft Teams Bot Setup

GoClaw can run as a Microsoft Teams bot in direct messages, group chats and team channels (the generated app package declares the `personal`, `groupChat` and `team` scopes). The implementation uses Bot Framework activities, a mounted webhook path, conversation reference persistence, optional mention filtering, file consent for outbound files, and attachment handling for inbound Teams files.

## What You Need

Teams setup has two separate Microsoft resources:

| Resource | Where | Purpose |
|---|---|---|
| App Registration | Microsoft Entra ID -> App registrations | Identity, client ID, tenant ID, client secret |
| Azure Bot | Azure Portal -> Azure Bot | Registers the bot with Microsoft Bot Framework and Teams |

The Teams app manifest `botId` must match the Azure Bot Microsoft App ID. If the Azure Bot was created from an existing app registration, this is usually the app registration Application ID. If Azure created a new Microsoft App ID, use the value shown in Azure Bot -> Configuration.

For single-tenant bots, set `channels.msteams.tenant_id`: GoClaw requests its Bot Framework token from that tenant. For multi-tenant bots, leave it empty; GoClaw then uses the `botframework.com` tenant.

## Runtime Paths

GoClaw serves the Teams webhook only when the Teams channel has started: `enabled: true` with `app_id` and `app_password` set, and the token request made at startup succeeded. When the start fails, the gateway log shows `Teams channel failed: ...`; until the channel starts, the path answers 404.

| Config | Messaging endpoint |
|---|---|
| No `gateway.webhook_secret_slug` | `https://<public-url>/hook/msteams` |
| With `gateway.webhook_secret_slug: "abc"` | `https://<public-url>/hook/abc/msteams` |

The public URL should normally come from `gateway.ingress.url`, either through ngrok or through a reverse proxy.

## Minimal Config

```yaml
gateway:
  ingress:
    type: "ngrok"                  # "ngrok", "proxy", "nginx", or empty
    url: "https://example.ngrok.app"
    authtoken: "${NGROK_AUTHTOKEN}"
    allow_preset: "microsoft-teams"

channels:
  dm_policy: "pairing"
  msteams:
    enabled: true
    app_id: "00000000-0000-0000-0000-000000000000"      # Azure Bot Microsoft App ID
    app_password: "${secret:TEAMS_APP_PASSWORD}"
    tenant_id: "11111111-1111-1111-1111-111111111111"   # empty for a multi-tenant bot
    bot_name: "GoClaw"
    require_mention: true
    app_package:                        # shown on the app's page in Teams; see below
      developer_name: "Example LLC"
      website_url: "https://example.com"
      privacy_url: "https://example.com/privacy"
      terms_of_use_url: "https://example.com/terms"
```

`app_id` and `tenant_id` are not secrets, so plain values are simplest; `${secret:NAME}` and `${VAR}` references also work in both. Keep `app_password` (the client secret) in the vault.

Use `gateway.webhook_secret_slug` if you want the public Teams path to be harder to guess:

```yaml
gateway:
  webhook_secret_slug: "team-prod-9f4c"
```

Then set the Azure Bot messaging endpoint to:

```text
https://<public-url>/hook/team-prod-9f4c/msteams
```

## Setup Flow

1. Create an App Registration in Microsoft Entra ID.
2. Create a client secret and save the secret value.
3. Create an Azure Bot resource and connect it to the app registration.
4. Enable the Microsoft Teams channel on the Azure Bot.
5. Set the Azure Bot messaging endpoint to the GoClaw Teams webhook path.
6. Generate a Teams App Package with `goclaw teams package`.
7. Upload the package in Microsoft Teams (custom app upload must be allowed in the tenant).
8. Start GoClaw and send a test message.

## Generate The Teams App Package

If `config.yaml` already contains `channels.msteams.*`, run:

```bash
goclaw teams package
```

Override values from the command line when needed:

```bash
goclaw teams package \
  --app-id="00000000-0000-0000-0000-000000000000" \
  --tenant-id="11111111-1111-1111-1111-111111111111" \
  --app-name="Ops Assistant" \
  --out="./teams-app.zip"
```

`--app-id`, `--tenant-id` and `--app-password` are also written to `config.yaml` as plain values (`--app-id` also sets `channels.msteams.enabled: true`), but only once the package has been built. The package itself needs only the App ID and the name, so leave `--app-password` out and keep the client secret in the vault. `--app-name` defaults to `channels.msteams.bot_name`, then to `GoClaw Bot`.

The zip holds three files at its root, which is where Teams looks for them:

| File | Content |
|---|---|
| `manifest.json` | Manifest version 1.17. `id` and `bots[0].botId` are both the App ID; `version` is `--app-version` (default `1.0.0`); `name.short` is the bot name cut to 30 characters; `developer` is your organization and its pages (below); `validDomains` is the host of `gateway.ingress.url`, if set. The bot is installable in the `personal`, `groupChat` and `team` scopes |
| `color.png` | 192×192 color icon |
| `outline.png` | 32×32 outline icon: white on a transparent background |

### Publisher and links

Teams shows the manifest's `developer` block on the app's details page: the publisher's name and links to its website, privacy policy and terms of use. Set them to your organization's own:

| Setting | Flag | Manifest field | Rule |
|---|---|---|---|
| `channels.msteams.app_package.developer_name` | `--developer-name` | `developer.name` | At most 32 characters |
| `channels.msteams.app_package.website_url` | `--website-url` | `developer.websiteUrl` | An `https://` URL |
| `channels.msteams.app_package.privacy_url` | `--privacy-url` | `developer.privacyUrl` | An `https://` URL |
| `channels.msteams.app_package.terms_of_use_url` | `--terms-of-use-url` | `developer.termsOfUseUrl` | An `https://` URL |

```bash
goclaw teams package \
  --developer-name="Example LLC" \
  --website-url="https://example.com" \
  --privacy-url="https://example.com/privacy" \
  --terms-of-use-url="https://example.com/terms" \
  --app-version=1.0.1
```

A flag wins over `config.yaml` and is saved there once the package is built, so the next package keeps it. `goclaw teams setup` asks for the four values too. Teams requires all four fields, so one left unset keeps GoClaw's, and the command names the ones it kept:

| Setting | GoClaw's value |
|---|---|
| `developer_name` | `Ulab LLC` |
| `website_url` | `https://goclaw.ulab.mn/` |
| `privacy_url` | `https://goclaw.ulab.mn/privacy.html` |
| `terms_of_use_url` | `https://goclaw.ulab.mn/terms.html` |

An app already in Teams shows the new links after you upload the new package with a higher `--app-version`.

### Icons

Without `--icon-source`, both icons are GoClaw's own (a white speech bubble). To use your logo:

```bash
goclaw teams package --icon-source=/path/to/logo.png
```

The source is a PNG, JPEG or GIF file, or an `http(s)` URL to one. It is fitted into both squares keeping its proportions: on a blue (`#0078D4`) background for `color.png`, and as a white silhouette for `outline.png`. The silhouette comes from the image's transparency; an image without any is cut from its background, taken to be the color of its top-left pixel. When that gives no usable shape (a photo, or an image of one color), the package keeps GoClaw's outline icon and the command says so. A logo on a transparent background gives the best outline. No external tool is needed: GoClaw resizes the image itself (`--ffmpeg` is ignored).

### Checks and versions

Before writing the zip, the command checks what Teams checks on upload, and refuses with an error instead of producing a package Teams would answer with `Bad Request`:

- the App ID is a GUID. An unresolved `${secret:NAME}` reference, or any other value, is refused; give the ID with `--app-id`;
- `--app-version` has the form `MAJOR.MINOR.PATCH`;
- the developer name has at most 32 characters, and the website, privacy and terms links are `https://` URLs;
- the icons are PNG files of exactly 192×192 and 32×32, and the outline has only white and transparent pixels.

Teams installs a changed package over an earlier one with the same App ID only when its version is higher. Raise `--app-version` (`1.0.1`, `1.0.2`, …) every time you upload a package again, for example after changing the name or the icons:

```bash
goclaw teams package --app-version=1.0.1
```

### Interactive setup

For an interactive flow, run:

```bash
goclaw teams setup
```

## Upload To Teams

For yourself, a chat or a team, in the Teams desktop or web client:

1. Select **Apps** > **Manage your apps** > **Upload an app**.
2. Select **Upload a custom app**.
3. Select `teams-app.zip`, then **Add**.
4. Select **Open** for a direct chat with the bot, or choose a team or chat to add it to.

"Upload a custom app" is shown only when the tenant allows it: a Teams administrator turns on **Upload custom apps** under Teams admin center > **Teams apps** > **Setup policies** (the Global policy, or one assigned to you), and under **Teams apps** > **Manage apps** > **Actions** > **Org-wide app settings** > **Custom apps**. A changed policy can take up to 24 hours to apply.

For the whole organization, a Teams administrator uploads the same zip in Teams admin center > **Teams apps** > **Manage apps** > **Actions** > **Upload new app**. Who can then install it follows the tenant's app permission policies.

To check a package before uploading it, import it in the Teams Developer Portal (`https://dev.teams.microsoft.com`, **Apps** > **Import app**) and open its validation: it names the field Teams objects to, where the upload in the client answers only `Bad Request`.

## Message Handling

Inbound Teams activities are handled by `internal/channel/msteams/msteams.go`.

- Outbound, the channel authenticates with the configured app ID and password.
- Inbound, every request must carry a Bot Framework token: signed by Microsoft (RS256), issued by `https://api.botframework.com`, with an audience equal to `channels.msteams.app_id` and, when the token names one, the same service URL as the activity. Any other request gets `401` and the log line `[teams] inbound auth rejected: ...`. Setting the environment variable `GOCLAW_TEAMS_SKIP_JWT=1` turns this check off, for the Bot Framework Emulator or a test with curl; never set it on a reachable gateway.
- Incoming activities are normalized into GoClaw inbound messages.
- Conversation references are saved in the gateway database on every inbound activity, so replies and proactive messages (cron jobs, SOP notices) can reach a conversation after a restart. At startup, references with no inbound activity for 30 days are deleted and the 1,000 most recent are loaded. A send to a conversation without a reference fails with `teams: no conversation reference for <id>`: someone in that conversation has to message the bot first.
- `require_mention` defaults to `false`: the bot answers every message in a group chat or channel. With `require_mention: true` it answers only messages that mention it or reply to one of its own messages.
- Every sender, in a direct message or a group chat, passes the global channel access policy (`channels.dm_policy`: pairing, allowlist, or open). Under `pairing`, an unapproved sender's message in a group chat is answered with the pairing reply in that chat. See [Pairing](pairing.md).
- While the agent works, the bot shows a placeholder message and replaces it with the whole answer when the turn ends; the answer is not updated token by token.
- Processing uses the shared inbound handler and `ProcessStream`, so Teams keeps the same agent, memory, tool, session, and canvas behavior as other channels.

Outbound markdown tables and standalone HTTPS images are sent as Adaptive Cards (`msteams_format.go`, `msteams_adaptive.go`), because Teams does not render markdown tables in bot messages. A card table cannot scroll sideways, so each column's width is estimated from its longest word (about 8px a character plus padding), and a table of more than six columns or wider than about 520px is reshaped first:

- up to three data rows: turned sideways, one row per column with its name on the left;
- more rows: split into as few tables as fit, of evenly many columns (at most six), each repeating the first column. Seven columns with long headers become two tables of four.

Column widths follow the longest word in each column, table cards use the full message width, and at most 50 data rows are shown (a table turned sideways keeps every column). A table too big for one message — Teams refuses one over about 100 KB — is sent as several cards of at most 60 KB each. An agent or SOP needs no change; it writes an ordinary markdown table.

## File Handling

Inbound Teams attachments are handled by `msteams_attachments.go`.

- Files shared with the paperclip and pasted images are downloaded only from Microsoft hosts (Bot Framework, Teams, SharePoint/OneDrive, Azure blob storage). A download URL on any other host is refused and logged as `refusing download from disallowed host`.
- Text files (`.txt`, `.md`, `.json`, `.csv`, `.log`, `.yaml`, source code and similar, or any `text/*` content type) are put into the message the agent reads, up to the first 256 KB. They are not saved.
- Other files (PDFs, Office documents, images) are saved to `uploads/` in the gateway workspace (`~/.goclaw/workspace/uploads/`; at most 50 MB is read), and the agent is told the path.
- Audio attachments are transcribed with the configured speech-to-text provider (`voice.stt_provider`) and the transcript goes into the message; if transcription fails, the file is saved as above.

Outbound files use the Teams file consent flow in `msteams_consent.go`.

- GoClaw sends a file consent card.
- If the user accepts, Teams provides an upload URL.
- GoClaw uploads the file through that URL.

## Ingress Notes

For local testing, ngrok is the fastest option:

```yaml
gateway:
  ingress:
    type: "ngrok"
    authtoken: "${NGROK_AUTHTOKEN}"
    url: "https://my-agent.ngrok.app"
    allow_preset: "microsoft-teams"
```

For production, prefer a reverse proxy with TLS and a dedicated ingress port. See [Ingress Configuration](../ingress.md).

With the built-in ngrok tunnel, requests reach GoClaw from the local tunnel (loopback, which the allowlist always lets through), so with `trust_proxy: false` (the default) `allow_preset` does not filter them; the Teams webhook is still protected by its Bot Framework token check (below). The allowlist filters callers that connect directly, or through a reverse proxy.

Without `gateway.ingress.port`, the webhooks share the main port, and `allow_preset` / `allow_ips` then apply to that whole port: the Web UI and the app API answer `403 Forbidden` to every address outside the list, except loopback. Set `gateway.ingress.port` so the allowlist covers only the webhook listener.

The Azure Bot endpoint should never point at the WebUI-only port unless that port also serves the Teams webhook. If `gateway.ingress.port` is configured, point the proxy or tunnel to that dedicated webhook port.

## Troubleshooting

| Symptom | Check |
|---|---|
| `Bad Request` while uploading a package an earlier GoClaw built without `--icon-source` | Its placeholder icons were 1×1 and `color.png` was not a valid PNG. Build the package again with the current `goclaw teams package` |
| `Bad Request` while uploading the package | Import it in the Teams Developer Portal to see which field fails. If an app with this App ID is already in Teams, build the package again with a higher `--app-version` |
| `invalid BotId` while uploading the package | The manifest `botId` must match Azure Bot -> Configuration -> Microsoft App ID |
| `goclaw teams package` says the App ID is not a GUID or not resolved | `channels.msteams.app_id` holds something other than the Azure Bot's Microsoft App ID, or a `${secret:NAME}` the shell cannot read from the vault. Give the ID with `--app-id` |
| The app's page in Teams links to GoClaw's site instead of yours | Set `channels.msteams.app_package.*` (or the flags) and upload a new package with a higher `--app-version`: see [Publisher and links](#publisher-and-links) |
| No "Upload a custom app" in Teams | Custom app upload is off for you: see [Upload To Teams](#upload-to-teams) |
| Azure Bot test shows 404 | The endpoint path must be `/hook/msteams` or `/hook/<slug>/msteams`. The path exists only when the Teams channel started: look for `Teams channel failed:` in the gateway log (wrong App ID or client secret, or no `app_password`) |
| Azure Bot test shows 401, log shows `[teams] inbound auth rejected` | `channels.msteams.app_id` must equal the Azure Bot's Microsoft App ID, which is the audience of the token Bot Framework sends. Check the host clock too (5 minutes of skew are allowed), and that the gateway can reach `login.botframework.com`, where Microsoft's signing keys are fetched |
| Web UI or app API answers `403 Forbidden` | `allow_preset` or `allow_ips` without `gateway.ingress.port` covers the whole gateway port. Set `gateway.ingress.port` |
| Group chat messages are ignored | Check `channels.msteams.require_mention` and mention the bot |
| Proactive messages fail with `teams: no conversation reference` | GoClaw holds no reference for that conversation: nobody there has messaged the bot, or not within 30 days (older references are dropped at startup). Have someone send it a message, and confirm the gateway database is writable so the reference persists |
| Attachments are not visible to the agent | Check Teams attachment type, download host, file size, and gateway logs |
| Outbound file transfer fails | Confirm the recipient accepted the Teams file consent card |

## Implementation Files

- `internal/channel/msteams/msteams.go`
- `internal/channel/msteams/msteams_attachments.go`
- `internal/channel/msteams/msteams_consent.go`
- `internal/channel/msteams/msteams_graph.go`
- `internal/cli/cmd_teams.go`
- `internal/cli/teams.go` (the package and its manifest)
- `internal/cli/teams_image.go` (the icons)
- `cmd/gateway/startup.go`
