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.
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.
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.
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:
gateway:
webhook_secret_slug: "team-prod-9f4c"
Then set the Azure Bot messaging endpoint to:
https://<public-url>/hook/team-prod-9f4c/msteams
goclaw teams package.If config.yaml already contains channels.msteams.*, run:
goclaw teams package
Override values from the command line when needed:
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 |
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 |
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.
Without --icon-source, both icons are GoClaw's own (a white speech bubble). To use your logo:
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).
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:
${secret:NAME} reference, or any other value, is refused; give the ID with --app-id;--app-version has the form MAJOR.MINOR.PATCH;https:// URLs;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:
goclaw teams package --app-version=1.0.1
For an interactive flow, run:
goclaw teams setup
For yourself, a chat or a team, in the Teams desktop or web client:
teams-app.zip, then Add."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.
Inbound Teams activities are handled by internal/channel/msteams/msteams.go.
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.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.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.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:
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.
Inbound Teams attachments are handled by msteams_attachments.go.
refusing download from disallowed host..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.uploads/ in the gateway workspace (~/.goclaw/workspace/uploads/; at most 50 MB is read), and the agent is told the path.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.
For local testing, ngrok is the fastest option:
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.
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.
| 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 |
| No "Upload a custom app" in Teams | Custom app upload is off for you: see 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 |
internal/channel/msteams/msteams.gointernal/channel/msteams/msteams_attachments.gointernal/channel/msteams/msteams_consent.gointernal/channel/msteams/msteams_graph.gointernal/cli/cmd_teams.gointernal/cli/teams.go (the package and its manifest)internal/cli/teams_image.go (the icons)cmd/gateway/startup.go