Skip to main content

Microsoft Teams โ€” Azure Bot setup

Runbook for clearing BLK-032: registering the Azure Bot and configuring the credentials the Teams channel adapter needs.

Design and gap analysis live in teams-integration-prd.md. This document is operational only.

Order matters. Do not run this until issue TEAMS-1 (fail-closed JWT) has landed. Configuring TEAMS_APP_ID today would mask the fail-open bug rather than fix it โ€” the endpoint would start validating for the wrong reason, and the underlying defect would ship dark.


What you needโ€‹

  • An Azure subscription with permission to create a Bot Service resource
  • A Microsoft 365 tenant with Teams, and rights to sideload or admin-approve an app
  • Access to the Humanwork API environment variables (Railway)
  • The API deployed on a public HTTPS endpoint (Bot Framework will not call an HTTP or private endpoint)

1. Register the botโ€‹

  1. Open the Azure portal โ†’ Create a resource โ†’ Azure Bot.

  2. Set Type of App to Single Tenant. Azure deprecated MultiTenant bot creation platform-wide โ€” the portal, the CLI and a direct ARM PUT all refuse it with "Multitenant bot creation is deprecated. Please use SingleTenant or UserAssignedMSI". How a SingleTenant bot then serves customers in other tenants is the open decision in #6208.

  3. Let Azure create a new Microsoft App ID, or supply an existing one.

  4. After the resource is created, open Configuration and set the Messaging endpoint:

    https://api.human.work/channels/teams/webhook

    Staging uses the staging host with the same path.

  5. Under Configuration โ†’ Microsoft App ID, click Manage โ†’ Certificates & secrets โ†’ New client secret. Copy the secret value immediately; Azure shows it once.

  6. Still under Manage, open Authentication โ†’ Add a platform โ†’ Web and register the consent callback (#6209). Without it, admin consent fails at the redirect:

    https://api.human.work/channels/teams/consent/callback
    http://localhost:3000/channels/teams/consent/callback # local development

    Or from the CLI:

    az ad app update --id <app-id> --web-redirect-uris \
    "https://api.human.work/channels/teams/consent/callback" \
    "http://localhost:3000/channels/teams/consent/callback"

2. Enable the Teams channelโ€‹

In the Bot resource, open Channels โ†’ Microsoft Teams โ†’ enable it and accept the terms. Without this the bot exists but Teams will not deliver activities to it.

3. Configure Humanworkโ€‹

Two levels. Both are required.

The tile is gated on these. GET /channels/teams/status reports configured: false unless the deployment has the app id, the app password AND a usable consent-state secret; the client Channels tile then renders Teams as "Coming soon" instead of offering a Set up button that cannot work. Setting the variables below is what makes Teams appear.

All three count. The password is what proves a tenant consented, and state signing THROWS on a deployed environment with no TEAMS_CONSENT_STATE_SECRET โ€” so a partial configuration would advertise a flow that fails on its first call.

Deployment level โ€” set in Railway for each deployed environment. Teams requires three variables before the tile can offer Set up; the redirect URI is only an override when the default callback is not the URI registered in Azure:

VariableValue
TEAMS_APP_IDthe Microsoft App ID from step 1
TEAMS_APP_PASSWORDthe client secret value from step 1
TEAMS_CONSENT_STATE_SECRETHMAC key signing the admin-consent state (#6209). Required in deployed environments, including staging and production โ€” the service refuses to start the flow without it, because that state is the only thing binding a consent callback to an org.
TEAMS_CONSENT_REDIRECT_URIOptional override; defaults to {PUBLIC_API_URL}/channels/teams/consent/callback. Set it only when the deployment must use a different callback, and make sure the final URI matches a redirect URI registered on the app.

Per-organization โ€” a client connects through Settings โ†’ Channels โ†’ Microsoft Teams, which runs the admin-consent flow in Connecting a client. That writes integration_credentials.azure_tenant_id and nothing else; clients never see or enter our app credentials.

The manual form below still exists for an operator connecting an org by hand (/integrations/new). It stores app credentials encrypted in integration_credentials (AES-256-GCM):

POST /orgs/:orgId/integrations
{
"integrationType": "teams",
"config": {
"app_id": "<Microsoft App ID>",
"app_password": "<client secret>"
}
}

Set tenant_id to the bot's tenant. This REVERSES earlier guidance in this file, for a concrete reason: Azure deprecated multi-tenant bot creation in 2026 โ€” az bot create --app-type MultiTenant now fails with "Multitenant bot creation is deprecated. Please use SingleTenant or UserAssignedMSI". Every new bot is SingleTenant, and a SingleTenant bot's Connector token must be issued by its OWN tenant.

Verified against the live registration:

Authoritytid in the issued token
login.microsoftonline.com/botframework.comd6d49420โ€ฆ (Microsoft's Bot Framework tenant)
login.microsoftonline.com/{tenantId}{tenantId} (the bot's tenant)

A SingleTenant bot presenting the first token is rejected by the Connector, so tenant_id is now BOTH a routing key (which org owns an inbound activity) and an authentication input (which authority issues outbound tokens). The code derives the authority in teams-token-authority.util.ts; set app_type: "MultiTenant" only for a legacy pre-deprecation registration.

Verify with:

POST /orgs/:orgId/integrations/:id/test

testTeams() performs a client-credentials token request against the AAD token endpoint. A failure here means the app id, secret or tenant is wrong, and no Teams traffic will work.

A client does not paste credentials. TEAMS_APP_ID / TEAMS_APP_PASSWORD are ours; handing them to customers is secret sprawl. The client-facing flow (#6209) is the Teams equivalent of Slack's "Add to Slack":

Settings โ†’ Channels โ†’ Microsoft Teams โ†’ Set up
โ†’ GET /channels/teams/consent/start?orgId=โ€ฆ (JWT-guarded, org-scoped)
redirect to login.microsoftonline.com/organizations/adminconsent
โ†’ the client's Microsoft 365 admin approves
โ†’ GET /channels/teams/consent/callback (public; trusts the signed state ONLY)
writes integration_credentials.azure_tenant_id for that org
โ†’ back to Settings โ†’ Channels with a result banner

The endpoint is v1 /adminconsent, not /v2.0/adminconsent: the v2 variant requires a scope parameter and rejects the request without one (AADSTS900144).

There is no code exchange. The /adminconsent endpoint returns tenant and admin_consent, never an authorization code โ€” consent grants the app permission inside the tenant rather than issuing us a token. Outbound already mints its own Connector token from the app's client credentials, so there is nothing per-customer to store beyond the tenant id (ADR-046 ยง5).

But those parameters are not proof. They arrive as plain query parameters on a public redirect; nothing about them is signed. The signed state binds the org, not the tenant, so a client holding a valid state for their own org could otherwise replay the callback with any tenant id they can look up โ€” and tenant ids are public, readable from any domain's OIDC metadata. Slack has no such hole because its code is exchanged with Slack and team_id comes back from Slack's API, not the URL.

So the callback proves it: it asks the claimed tenant for a client-credentials token for our app. That succeeds only if our application has a service principal there, which is exactly what admin consent creates; a tenant that never consented fails with AADSTS700016. The token's tid claim is then authoritative and is compared against the claim. The check fails closed โ€” an unreachable token endpoint refuses the binding.

After consent, the admin still uploads the app package โ€” Download app package on the connected card serves it from GET /channels/teams/app-package, with botId stamped to the app id this deployment actually authenticates with.

Disconnect goes through the ordinary CredentialsService.delete teardown: sessions are re-routed to web, the credential row (and with it the tenant binding) is removed, and teams_thread_routes is deliberately left intact โ€” ADR-046 forbids offboarding from cascading into durable routing state.

Cross-tenant reach is an open questionโ€‹

Azure deprecated MultiTenant bot creation platform-wide, so the bot resource is bound to our own tenant. A foreign tenant can complete consent, but whether its messages reach our bot depends on how #6208 is resolved. The flow above is required under every option there.

4. Package the Teams appโ€‹

Bot Framework registration alone does not put the bot in Teams. You need an app package: a zip containing manifest.json, a 192ร—192 colour icon and a 32ร—32 outline icon.

Minimum manifest fields:

{
"manifestVersion": "1.17",
"id": "<Microsoft App ID>",
"name": { "short": "Humanwork" },
"bots": [{
"botId": "<Microsoft App ID>",
"scopes": ["personal", "team", "groupchat"],
"supportsFiles": true,
"isNotificationOnly": false
}],
"permissions": ["identity", "messageTeamMembers"],
"validDomains": ["api.human.work"]
}

supportsFiles: true is required for inbound file attachments (TEAMS-8). scopes must include team for channel installs and personal for one-to-one chats.

Sources live in api/config/teams-app-package/; GET /channels/teams/app-package builds the zip from them on demand. Install by sideloading in Teams (Apps โ†’ Manage your apps โ†’ Upload a custom app) for testing, or through the tenant admin centre for a real client.

5. Verifyโ€‹

  1. Send the bot a direct message in Teams. The API log should show Teams activity: type=message.
  2. Confirm a session was minted for the org and a reply was delivered.
  3. Post in a channel where the bot is installed without mentioning it โ€” once TEAMS-6 lands, this should produce no turn.
  4. Mention the bot in that channel. This should open an engagement and produce a reply in-thread.
  5. Reply in the same thread without a mention. This should continue the same session.

Testing locally without Azureโ€‹

Everything in issues #6188-#6196 is testable with synthetic Bot Framework activities. No tenant, no bot registration, no tunnel. Only #6198 acceptance needs the real thing.

./dev-up.sh # postgres, redis, api, agent
bash scripts/test-teams-channel.sh # seed + post synthetic activities

The harness seeds the tenant binding against the demo org, then exercises unknown-tenant quarantine, the thread model (two threads -> two sessions, a reply continuing one), redelivery dedup, and the self-echo guard. It tails the api log for the matching lines.

Leave TEAMS_APP_ID unset locally. The adapter fails closed in production but processes-and-warns outside it, and that non-production branch is what lets an unsigned synthetic activity through. Setting the app id locally makes every test request 401.

Local .env gotchaโ€‹

docker-compose.yml carries two sets of credential defaults that do not agree. Postgres initialises with POSTGRES_PASSWORD (default Hw2026!Postgres#SecureDefault) and Redis with REDIS_PASSWORD (default Hw2026!Redis#SecureDefault), but the api service connects with Hw2026%21Postgres and Hw2026%21Redis โ€” no #SecureDefault suffix. A .env copied straight from .env.example also leaves REDIS_PASSWORD as CHANGE_ME_redis_password, which Redis then genuinely requires.

The result is an api container that boots, fails auth, and crash-loops with password authentication failed then WRONGPASS. Set these four to match the api's own defaults:

POSTGRES_PASSWORD=Hw2026!Postgres
REDIS_PASSWORD=Hw2026!Redis
DATABASE_URL=postgresql://humanwork:Hw2026%21Postgres@postgres:5432/humanwork
REDIS_URL=redis://:Hw2026%21Redis@redis:6379

Postgres only reads POSTGRES_PASSWORD on first init, so an already-created volume needs docker exec -i humanwork-postgres psql -U humanwork -d humanwork -c "ALTER USER humanwork WITH PASSWORD 'Hw2026!Postgres';" rather than a .env edit alone.

This is pre-existing and not Teams-specific, but it blocks any local run.

Session minting needs a Turso tokenโ€‹

resolveOrMintForChannel provisions a per-org Turso SessionDB (ADR-046 ยง5). There is no local or embedded mode: OrgDatabaseProvisionerFactory selects the Turso Cloud provisioner only when TURSO_API_TOKEN is set, and without it the route reaches the runtime and fails with TURSO_API_TOKEN must be set to provision org databases on Turso Cloud.

Everything up to that point is still observable โ€” JWT branch, tenant resolution, quarantine, the echo guard, enqueue and job consumption โ€” which is why the harness skips the thread-model assertions rather than reporting them green. Set TURSO_API_TOKEN (plus TURSO_ORG, TURSO_GROUP) in .env to exercise minting and the reply path end to end.

One interaction worth knowing: with no SessionDB every teams-route job fails terminally, and the #2110 handler then CLEARS the inbound dedup key so the vendor may re-deliver. A second post of the same activity id is therefore legitimately not a duplicate, so the dedup gate cannot be observed in that state โ€” only the clear. That is the recovery path working, not a gap.

Seeding a testable orgโ€‹

npm run seed:browser-dev fails to compile under plain ts-node โ€” the seed path has never been type-clean (Promise.withResolvers needs lib: es2024, and there are further latent errors behind it). Run it the way the app itself runs, transpile-only:

docker compose exec -T api npx ts-node --transpile-only \
-r tsconfig-paths/register scripts/seed-browser-dev.ts

That produces a real org with a published agent release. Hand-seeding an org by SQL is not enough: mintSession requires the specialist to belong to the org, and the runtime needs a published release, so a hand-made org fails with foreign-key violations partway through the mint.

Then point the tenant binding at it (azure_tenant_id is uniquely indexed, so release it from any previous holder first):

UPDATE integration_credentials SET azure_tenant_id = NULL
WHERE integration_type = 'teams' AND azure_tenant_id = '<tenant>';
INSERT INTO integration_credentials
(org_id, integration_type, encrypted_config, metadata, azure_tenant_id, status)
VALUES ('<seeded-org-id>', 'teams', '{}', '{}'::jsonb, '<tenant>', 'active')
ON CONFLICT (org_id, integration_type)
DO UPDATE SET azure_tenant_id = EXCLUDED.azure_tenant_id;

What a successful local run looks likeโ€‹

With TURSO_API_TOKEN, TURSO_ORG and TURSO_GROUP set and a seeded org bound, a synthetic activity produces:

TeamsController: no app id resolved โ€” JWT validation skipped (non-production only)
TeamsController: Teams activity: type=message
ChannelsInboundProcessor: teams-route <- consumed, not "Unknown job type"
SessionsService: SessionDB ... has no sessions table yet โ€” treating as a miss and minting
ManagedHermesTurnService: provisioning the initial release automatically
Teams dispatch skipped: credentials are not configured

That last line is the expected end of a local run, not a failure. The inbound half is fully exercised; outbound declines because TEAMS_APP_ID / TEAMS_APP_PASSWORD are deliberately unset. Seeing a reply actually posted needs either real credentials plus a real tenant, or the Emulator below.

A harmless warning also appears on a fresh SessionDB: auto-title failed ... UNIQUE constraint failed: sessions.title. It is caught and does not affect delivery.

Using the Bot Framework Emulatorโ€‹

For an interactive local test, point the Bot Framework Emulator at http://localhost:3000/channels/teams/messages with the app id and password left blank. The Emulator sends real activity shapes and renders replies, so it exercises the outbound path the synthetic harness cannot.

Troubleshootingโ€‹

Activities arrive but no reply is posted. dispatchTeams() returns early when credentials are missing or when the inbound Bot Framework routing fields are incomplete. Check that teamsServiceUrl, teamsConversation and teamsRecipient were captured on the inbound, and that the org credential resolves.

401 from the Connector API. The AAD token was acquired against the wrong tenant, or the secret has expired. Client secrets expire โ€” check the expiry in Azure before assuming a code fault.

Nothing arrives at all. Confirm the messaging endpoint is reachable over public HTTPS and that the Teams channel is enabled on the bot resource. Bot Framework will not retry indefinitely against an unreachable endpoint.

Inbound quarantined as org_resolution_required. The tenant id on the activity does not match any stored Teams credential. Check tenant_id in the org's integration config against channelData.tenant.id on the activity.

Secret handlingโ€‹

app_password is a bearer credential for the bot identity. It goes in Railway environment variables and the encrypted integration_credentials column, and nowhere else โ€” never in the repository, an issue, a Notion page, or a log line. Rotate it in Azure and update both locations together; there is no runtime reload, so a rotation needs a deploy.