OneLinersCommand workbench
Guides
AI/ML / Services & Applications / Software Engineering

Build a production Microsoft Teams assistant with the Agents SDK

Build a tenant-isolated Microsoft Teams assistant on the current Microsoft 365 Agents SDK with CloudAdapter JWT authentication, Microsoft Entra ID workload identity, durable activity idempotency, bounded read-only answers, Adaptive Card Action.Execute approvals, distinct app and user authorization, explicit admin-consent and resource-specific consent review, a separate dispatcher, and zero-downtime deployment.

360 min14 stepsChanges system stateRevision 1
Save or explore
Save to collectionCreate a collection in the sidebar first.
0 of 14 steps completed
Goal

Deliver a runnable Node.js and PostgreSQL Teams assistant that accepts authenticated Bot Service activities through CloudAdapter, derives tenant and actor scope only after channel authentication, denies unknown tenants, stores activity IDs uniquely per tenant, keeps state per tenant and conversation, moderates input and output, uses deterministic Adaptive Cards, and prevents any consequential write until an enabled tenant approver authorizes the immutable exact payload. Production uses managed identity or federated identity rather than a client secret, and any Microsoft Graph or Teams resource-specific permission is introduced only through documented administrator consent.

Supported environments
  • Node.js 22.22.2
  • Microsoft 365 Agents SDK for JavaScript 1.7.1
  • Microsoft Teams current as verified 2026-07-29
  • Adaptive Cards 1.5
  • PostgreSQL 17.x
  • OpenAI Node SDK 7.1.0
Prerequisites
  • Microsoft Teams application ownership Create a nonproduction application or bot registration, name its platform owner, data owner, security reviewer, on-call responder, approval role, and dispatcher role, and document the supported event and installation scopes before code is deployed.The registration, callback, permissions, scopes, owners, review date, incident contact, and teardown procedure are recorded without secret values.
  • Server identity and secret manager Provision separate workload or application identity, provider project credential, database roles, approval identity, dispatcher identity, telemetry identity, and backup identity. Prefer managed, federated, or certificate credentials when the platform supports them.Each identity can perform only its documented operation, rotation is rehearsed, and no browser, message, card, attachment, test fixture, or image contains a reusable secret.
  • Data and moderation policy Classify Microsoft Teams messages, identifiers, scope metadata, attachments, state, model input and output, action drafts, audit, telemetry, backup, and deletion. Define moderation, retention, consent, export, and hold rules by supported context.Synthetic policy fixtures cover allowed, refused, unsafe, sensitive, oversized, duplicate, cross-scope, stale, consequential, and deletion cases.
  • Recovery and platform reconciliation Document how to query authoritative response state, reconcile ambiguous writes, disable ingress, disable dispatch, restore the database without contacting users, and roll back registration, permission, command, manifest, SDK, and application revisions.A game day demonstrates platform-disabled recovery, fake adapters, backup restore, audit export, and kill switches.
Operating boundary

OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.

Full guide

What you will build

System
  • An authenticated Microsoft Teams ingress and durable worker architecture that acknowledges inside the platform deadline, deduplicates events, scopes state, moderates content, and responds through reviewed endpoints.
  • A permission and attachment boundary that validates application and actor scope before model use, downloads only approved bounded files, and keeps raw content out of ordinary telemetry and audit.
  • A human-approval workflow for consequential writes with immutable drafts, short-lived approver identity, separate dispatcher credential, idempotency, authoritative reconciliation, and privacy-minimized audit.
  • A pinned, observable, recoverable deployment with deterministic tests, shared rate and state controls, zero-downtime overlap, encrypted backup, minimized export, rollback, and teardown.
Observable outcome
  • Authentic Microsoft Teams events are acknowledged promptly and processed exactly once across retries and deployment overlap.
  • Permissions, moderation, attachments, state isolation, rate limits, and output safeguards are enforced locally rather than delegated to model behavior.
  • The assistant remains read-only by default and cannot turn a request or generated suggestion into a consequential platform or external write without human approval.
  • Operators can trace, recover, export, roll back, or retire the bot without exposing conversation content or losing action accountability.

Architecture

How the parts fit together

Expose one HTTPS `/api/messages` endpoint behind an Azure Bot registration. Let the current Agents SDK validate bearer tokens and create the turn context; do not implement a home-grown JWT parser. After authentication, require channel `msteams`, normalize `channelData.tenant.id`, compare it with the deployment allowlist and active tenant policy, require activity, conversation, and actor identifiers, and persist the activity ID under the tenant key before responding. A thin ingress authenticates and bounds the exact request, claims a scoped event in PostgreSQL, and returns the required acknowledgement. A durable worker loads only required state, enforces permissions and moderation, processes approved attachments, calls the model under explicit limits, validates output, and updates the original response with platform-aware throttling. Consequential intent becomes a local draft. An authenticated human approves exact content, and a separate dispatcher calls only the allowlisted operation. Audit, telemetry, backup, and recovery observe lifecycle without storing ordinary message content.

Azure Bot and Agents SDK ingressUses authorizeJWT and CloudAdapter for authenticated channel turns, then permits only msteams activities from active allowlisted tenants.
Tenant-scoped PostgreSQL stateDeduplicates activities, expires text-free conversation metadata, stores operator roles, and binds every draft and audit record to a tenant.
Moderated read-only answer pathBounds and moderates /ask input, generates one response without tools, removes mentions and links, moderates final text, and stores no raw conversation.
Deterministic Adaptive Card approvalPresents exact server-owned draft summaries through Action.Execute while card data carries only an allowlisted verb and immutable draft ID.
Separate approved-action dispatcherRevalidates payload hash, uses a distinct secret and fixed destination, sends one idempotent approved operation, and quarantines ambiguous results.
  1. The Microsoft Teams channel sends an event to the registered HTTPS endpoint. The server authenticates exact bytes or validated channel credentials before JSON parsing.
  2. Ingress enforces body and scope bounds, inserts a unique scoped event and durable work item, then returns the platform acknowledgement inside its deadline.
  3. A worker locks the item, loads scoped state, verifies actor and application permissions, applies moderation and attachment policy, and requests a bounded response.
  4. Local code validates content length, formatting, mentions, cards, identifiers, and allowed operation. Visible progress edits are throttled and respect platform rate guidance.
  5. Read-only responses update the intended original conversation target. Consequential intent becomes an immutable draft with no external effect.
  6. A human approver reviews exact scope and payload. A separate dispatcher performs the allowlisted operation once and records authoritative response evidence or blocks ambiguity.
  7. Metrics and traces contain only controlled operational attributes. Backup and export preserve event, approval, and audit state; rollback and teardown disable dispatch first.

Assumptions

  • The Microsoft Teams application is registered to a controlled nonproduction scope first, and all callback or messaging traffic uses HTTPS plus official authentication.
  • The platform can redeliver events and an HTTP response can be lost after commit; durable idempotency and reconciliation are therefore required.
  • Messages, event fields, attachment metadata and content, card values, and quoted text are untrusted and may contain prompt injection or sensitive data.
  • No model receives platform administration, user management, channel write, file write, or business-system tools; consequential work remains a human-approved draft.
  • The deployment has PostgreSQL or equivalent transactional state, secret or identity management, a private telemetry collector, encrypted backup, and a fake platform adapter for recovery tests.
  • Retention and deletion for platform records, provider data, database state, telemetry, backups, external destinations, and exports are documented independently.

Key concepts

Microsoft 365 Agents SDK ingress
The current AgentApplication, authorizeJWT, and CloudAdapter path that authenticates Bot Service activities and routes them through one controlled application lifecycle.
Application authentication versus user authorization
Bot credentials prove the application to the channel; they do not prove that a Teams user may read a Graph resource or authorize an external business action.
Admin consent and resource-specific consent
Entra administrators grant tenant-wide delegated or application permissions, while Teams RSC grants declared permissions over a specific team, chat, or meeting resource. Both require explicit inventory and revocation.
Action.Execute approval
An Adaptive Card server action carrying an allowlisted verb and draft identifier; the server reloads and validates authoritative state instead of trusting action data as the payload.
Tenant isolation
Every activity, conversation, operator, draft, audit, rate bucket, and query is keyed by the authenticated Teams tenant and cannot be selected by another tenant.

Before you copy

Values used in this guide

{{backupPath}}

Restrictive path for the encrypted or immediately encrypted PostgreSQL custom-format bot state backup.

Example: /secure-backups/wyd308-microsoft-teams-agents-sdk-assistant-20260729.dump
{{exportPath}}

Restrictive path for the privacy-minimized lifecycle audit JSONL export used during recovery and handoff.

Example: /secure-backups/wyd308-microsoft-teams-agents-sdk-assistant-audit-20260729.jsonl

Security and production boundaries

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.

Stop before continuing if

  • Authenticity validation fails, occurs after parsing, or uses a secret or public key revision different from the registered application.
  • Event idempotency or conversation state can collide across tenant, guild, chat, channel, conversation, installation, or user boundary.
  • Unsafe content, unsupported attachment, missing permission, or consequential intent reaches generation or platform dispatch without the reviewed gate.
  • A platform or external write occurs without exact-content human approval, or an ambiguous write is automatically retried.
  • Telemetry, logs, tests, backup metadata, or export contain raw message, attachment, card, model output, token, signature, or stable user identity.
01

warning

Define the Microsoft Teams assistant boundary

read-only

Accept only Microsoft Teams activities that pass the Microsoft 365 Agents SDK `authorizeJWT` middleware and `CloudAdapter` processing. Enable only reviewed tenants, `/help`, `/ask`, `/request`, and two exact Adaptive Card verbs. Ordinary bot answers are bounded conversation output. Microsoft Graph calls, proactive broadcasts, team or channel changes, member management, file operations, meeting operations, and external business writes are excluded from the model path and require separately reviewed permissions, exact-payload approval, and dispatch.

Why this step matters

A conversational interface can make a suggestion look like an action and a channel identity look like broad authorization. The boundary must therefore separate authenticated ingress, untrusted content, read-only response generation, local state, human approval, and external dispatch. The `/request` command creates an immutable `notify_operations` draft with exact tenant, conversation, summary, payload hash, expiry, and requesting actor hash. A deterministic Adaptive Card uses `Action.Execute` only to send `approveDraft` or `cancelDraft` plus the draft ID. The server authenticates the invoke activity, reloads the tenant-scoped draft under a lock, requires an enabled approver, re-hashes exact payload, and records approval. A separate dispatcher identity posts the fixed payload to one allowlisted internal service with an idempotency key. Editing, expiry, cross-tenant use, non-operator use, or ambiguous dispatch blocks execution.

What to understand

Expose one HTTPS `/api/messages` endpoint behind an Azure Bot registration. Let the current Agents SDK validate bearer tokens and create the turn context; do not implement a home-grown JWT parser. After authentication, require channel `msteams`, normalize `channelData.tenant.id`, compare it with the deployment allowlist and active tenant policy, require activity, conversation, and actor identifiers, and persist the activity ID under the tenant key before responding.

Separate three permission planes. Application authentication allows the bot to exchange channel activities; it does not grant a user permission to access Microsoft Graph data. Delegated or application Graph scopes require a named purpose and Entra consent, while Teams resource-specific consent is declared in the app manifest and granted for a specific team or chat resource. The baseline requests neither Graph nor RSC access. If a future capability needs either, add the minimum permission, authorize the authenticated tenant and actor again, document admin consent and revocation, and never infer permission from a display name, card field, or tenant supplied by the browser.

Treat message text, quoted text, card input, invoke data, display names, and future attachment metadata as untrusted. Moderate bounded `/ask` input before generation and the complete response before posting. Strip model-produced mentions and links, render ordinary text rather than model-authored cards, and build approval cards from deterministic server data. Never allow the model to select an Adaptive Card verb, draft ID, tenant, dispatcher destination, Graph scope, or external action.

Document which replies are public, private, or scoped to a conversation; which identifiers are retained; which data may be sent to a model; and which failure modes must return a neutral message instead of guessing.

System changes

  • Creates an operating, data, and authorization policy; it does not register the application, receive real traffic, or write to an external channel.
Example output / evidence
TEAMS_BOUNDARY_OK activities=authenticated tenants=1 graph_scopes=0 rsc_permissions=0 model_actions=0 approval=required

Checkpoint: Boundary review completed

Continue whenThe approved policy names every accepted event, response type, state field, moderation stage, permission check, retention class, approval role, dispatcher role, and kill switch.

Stop whenStop if the model can call channel administration, user management, message deletion, posting, file, or other consequential APIs without exact-content human approval.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.

Alternatives

  • Launch with deterministic help and status commands only, then add generated responses after authentication, state, moderation, and evaluation are measured.

Stop conditions

  • The platform authentication mechanism or tenant/guild/chat boundary is not understood.
  • A required consequential action has no accountable human approver and reconciliation procedure.
02

config

Create the pinned server project

caution

Create a private Node.js project with exact runtime and dependency versions for Microsoft Teams. Separate start, worker, syntax, and deterministic test scripts. Install from a reviewed lockfile with lifecycle scripts disabled in CI. The browser or channel client receives no reusable provider, application, database, dispatcher, or observability credential.

Why this step matters

Pinned packages make authentication, event parsing, response timing, retry, and state behavior reproducible. Bot Service activity handling must not wait on unbounded model, Graph, attachment, or business operations. The reference claims the activity durably, sends a typing indicator for bounded read-only generation, and keeps consequential work as a local draft. Long external actions are performed by the separately authorized dispatcher after approval, with stable idempotency and reconciliation instead of blind retries. A distinct worker script also ensures that acknowledging an event is not delayed by model generation or attachment processing.

What to understand

Generate secrets in the deployment secret manager, pin the runtime and lockfile, and record platform application ID, credential revision, provider model, schema revision, and deployment image in restricted release evidence.

Apply shared tenant-and-actor minute buckets before message or invoke processing, plus tenant-wide model and dispatcher budgets. Respect Bot Service, Teams, Microsoft Graph, and downstream `Retry-After` responses where the exact operation is known safe to retry. Do not replay an activity already claimed, an invoke already approved, or an external write with an ambiguous outcome. Keep the ingress pool independent from the dispatcher.

Run dependency, license, syntax, and secret scans before building an immutable image. Updates are reviewed changes with contract tests and rollback, not an automatic deployment side effect.

System changes

  • Creates package metadata and a lockfile after installation; no platform registration, secret, database row, channel message, or user state is created.

Syntax explained

npm ci --ignore-scripts
Installs exactly the lockfile graph while suppressing package lifecycle execution in the trusted build.
separate start and worker scripts
Keeps platform acknowledgement fast while expensive or retryable work runs from durable state.
File package.json
Configuration
{
  "name": "production-teams-assistant",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": "22.22.2"
  },
  "scripts": {
    "start": "node src/teams.mjs",
    "worker": "node src/teams.mjs --worker",
    "check": "node --check src/teams.mjs && node --check test/teams.test.mjs",
    "test": "node --test test/teams.test.mjs"
  },
  "dependencies": {
    "@microsoft/agents-hosting": "1.7.1",
    "express": "5.1.0",
    "helmet": "8.1.0",
    "openai": "7.1.0",
    "pg": "8.16.3",
    "pino": "9.7.0"
  }
}
Example output / evidence
TEAMS_PROJECT_OK node=22.22.2 agents_sdk=1.7.1 syntax=passed secrets_in_lock=0

Checkpoint: Project contract verified

npm ci --ignore-scripts && npm run check

Continue whenThe reviewed lockfile installs, syntax checks pass, required scripts exist, and tracked files plus image layers contain no production secret.

Stop whenStop if dependencies float, install scripts execute unexpectedly, runtime differs, or one credential grants ingress, approval, and dispatch.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.
03

config

Document identity, secret, limit, and destination configuration

caution

Add the non-secret environment template for Microsoft Teams. Validate every required value at process start, reject unsafe URLs and invalid numeric bounds, and resolve real values only through the deployment secret or identity system. Use separate credentials for platform ingress or API calls, model access, human approval, external dispatch, database migration, runtime data, backup, and telemetry.

Why this step matters

A complete configuration contract prevents insecure defaults and makes deployment review possible. Separate three permission planes. Application authentication allows the bot to exchange channel activities; it does not grant a user permission to access Microsoft Graph data. Delegated or application Graph scopes require a named purpose and Entra consent, while Teams resource-specific consent is declared in the app manifest and granted for a specific team or chat resource. The baseline requests neither Graph nor RSC access. If a future capability needs either, add the minimum permission, authorize the authenticated tenant and actor again, document admin consent and revocation, and never infer permission from a display name, card field, or tenant supplied by the browser. Apply shared tenant-and-actor minute buckets before message or invoke processing, plus tenant-wide model and dispatcher budgets. Respect Bot Service, Teams, Microsoft Graph, and downstream `Retry-After` responses where the exact operation is known safe to retry. Do not replay an activity already claimed, an invoke already approved, or an external write with an ambiguous outcome. Keep the ingress pool independent from the dispatcher. Values that control tenant, guild, chat, channel, callback host, attachment size, response cadence, and state retention are security policy rather than user options.

What to understand

Expose one HTTPS `/api/messages` endpoint behind an Azure Bot registration. Let the current Agents SDK validate bearer tokens and create the turn context; do not implement a home-grown JWT parser. After authentication, require channel `msteams`, normalize `channelData.tenant.id`, compare it with the deployment allowlist and active tenant policy, require activity, conversation, and actor identifiers, and persist the activity ID under the tenant key before responding.

Reject attachments in the baseline rather than silently uploading them to a model. A future attachment profile must authorize the Teams or Graph resource, request the smallest delegated or RSC permission, validate hostname, redirect, MIME, file size, malware result, retention, and tenant scope, stream with a hard byte cap, and obtain separate consent before provider submission.

Keep destination URLs fixed, HTTPS, allowlisted, credential-free, and redirect-rejecting. Use destination-specific credentials instead of sending an internal dispatcher credential to another service.

Measure Agents SDK authentication rejection, tenant denial, activity type and latency, duplicate activity rate, conversation-state expiry, moderation class, model status and usage, Adaptive Card verb class, approver denial, draft age, payload mismatch, dispatcher state, ambiguous writes, admin-consent drift, app-manifest revision, service health, backup age, and isolated restore. Use tenant- and actor-scoped HMAC identifiers; never export authorization headers, client secrets, tokens, raw message text, card input, conversation IDs, attachment URLs, Graph objects, or exact external payloads.

System changes

  • Creates a non-secret configuration template and startup validation checklist; production values remain in managed identity, certificate, key vault, or secret storage.

Syntax explained

independent credential revisions
Allows ingress, platform API, provider, approval, dispatch, database, and telemetry access to rotate or revoke without widening another role.
bounded numeric configuration
Rejects excessive request, attachment, output, retry, edit, retention, and concurrency values before accepting traffic.
File .env.example
Configuration
PORT=3978
NODE_ENV=production
# Microsoft 365 Agents SDK reads these exact names.
tenantId=11111111-1111-1111-1111-111111111111
clientId=22222222-2222-2222-2222-222222222222
# For local development only; omit in Azure production and use the matching
# user-assigned managed identity or federated identity credential.
clientSecret=resolve-from-local-secret-store
ALLOWED_TENANT_IDS=11111111-1111-1111-1111-111111111111
IDENTITY_HMAC_SECRET=resolve-from-secret-store
DATABASE_URL=postgres://teams_runtime@db.internal/teams_assistant?sslmode=verify-full
OPENAI_API_KEY=resolve-from-secret-store
OPENAI_MODEL=gpt-5.4-nano
MAX_MESSAGE_CHARS=6000
MAX_OUTPUT_CHARS=8000
REQUESTS_PER_MINUTE=12
STATE_TTL_HOURS=24
APPROVAL_TTL_MINUTES=30
ACTION_DISPATCH_URL=https://dispatcher.example.internal/v1/teams-actions
ACTION_DISPATCH_TOKEN=resolve-from-separate-secret-store
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.internal/v1/traces
Example output / evidence
TEAMS_ENV_OK auth=managed_identity tenant_allowlist=1 message_limit=6000 dispatcher_host=allowlisted secret_values=hidden

Checkpoint: Configuration boundary verified

Continue whenAll values resolve under the workload identity, secrets are absent from source and images, limits match policy, callback and destination hosts are allowlisted, and telemetry contains no content.

Stop whenStop if a request controls tenant, destination, model, permission, or callback URL; a credential is logged; or a client bundle contains a server secret.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.
04

config

Create durable idempotency, state, approval, audit, and rate tables

caution

Apply the Microsoft Teams schema using a migration identity, not the runtime account. Persist an immutable platform event identifier before work, keep conversation state scoped to the correct security boundary, store only bounded redacted content when the purpose requires it, model consequential work as draft and approved states, and coordinate rate or edit leases across replicas.

Why this step matters

Durable idempotency is the trust anchor for webhook retries, zero-downtime overlap, and response reconciliation. Use `(tenant_id, event_id)` for delivery idempotency and `(tenant_id, conversation_id)` for conversation state. Hash the authenticated actor ID with a deployment secret and tenant prefix. Persist only command class and response class in expiring state, not raw messages. Approval and operator rows are tenant-scoped; the dispatcher re-hashes exact payload JSON and never accepts a replacement body from the card action. A constrained lifecycle prevents a model or duplicate event from turning arbitrary text into authorization state.

What to understand

Use a migration owner, restricted runtime role, backup role, and dispatcher role. Revoke public schema creation, require encrypted connections, and include tenant, guild, chat, channel, or conversation scope in every key and query where isolation requires it.

The `/request` command creates an immutable `notify_operations` draft with exact tenant, conversation, summary, payload hash, expiry, and requesting actor hash. A deterministic Adaptive Card uses `Action.Execute` only to send `approveDraft` or `cancelDraft` plus the draft ID. The server authenticates the invoke activity, reloads the tenant-scoped draft under a lock, requires an enabled approver, re-hashes exact payload, and records approval. A separate dispatcher identity posts the fixed payload to one allowlisted internal service with an idempotency key. Editing, expiry, cross-tenant use, non-operator use, or ambiguous dispatch blocks execution.

State expiry is explicit and observable. Deletion jobs record counts and policy revision without retaining deleted content. Legal or security holds are separate reviewed state, not a free-form model label.

System changes

  • Creates platform event, scoped conversation state, action draft, audit, shared rate, and worker queue records plus required constraints and indexes.

Syntax explained

unique scoped platform event ID
Makes redelivery and overlapping revisions idempotent without treating an identifier from another tenant, guild, or chat as the same event.
draft → approved → dispatched lifecycle
Separates model proposal, human authorization, and external effect while preserving exact-content audit evidence.
File db/001_teams.sql
Configuration
BEGIN;

CREATE TABLE IF NOT EXISTS bot_schema_revision (
  revision text PRIMARY KEY,
  applied_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO bot_schema_revision (revision)
VALUES ('teams-v1')
ON CONFLICT (revision) DO NOTHING;

CREATE TABLE IF NOT EXISTS teams_tenant_policy (
  tenant_id uuid PRIMARY KEY,
  display_name text NOT NULL,
  active boolean NOT NULL DEFAULT false,
  policy_revision text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  CHECK (char_length(policy_revision) BETWEEN 1 AND 80)
);

CREATE TABLE IF NOT EXISTS teams_events (
  tenant_id uuid NOT NULL REFERENCES teams_tenant_policy(tenant_id),
  event_id text NOT NULL,
  conversation_id text NOT NULL,
  actor_hash text NOT NULL,
  activity_type text NOT NULL,
  body_hash text NOT NULL,
  status text NOT NULL DEFAULT 'received'
    CHECK (status IN ('received', 'processing', 'completed', 'refused', 'failed')),
  response_activity_id text,
  created_at timestamptz NOT NULL DEFAULT now(),
  completed_at timestamptz,
  PRIMARY KEY (tenant_id, event_id),
  CHECK (char_length(event_id) BETWEEN 1 AND 240),
  CHECK (char_length(conversation_id) BETWEEN 1 AND 500)
);

CREATE TABLE IF NOT EXISTS teams_conversation_state (
  tenant_id uuid NOT NULL REFERENCES teams_tenant_policy(tenant_id),
  conversation_id text NOT NULL,
  state_json jsonb NOT NULL DEFAULT '{}'::jsonb,
  expires_at timestamptz NOT NULL,
  updated_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, conversation_id),
  CHECK (jsonb_typeof(state_json) = 'object')
);

CREATE TABLE IF NOT EXISTS teams_operators (
  tenant_id uuid NOT NULL REFERENCES teams_tenant_policy(tenant_id),
  actor_hash text NOT NULL,
  role text NOT NULL CHECK (role IN ('approver', 'auditor')),
  enabled boolean NOT NULL DEFAULT true,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, actor_hash, role)
);

CREATE TABLE IF NOT EXISTS teams_action_drafts (
  id uuid PRIMARY KEY,
  tenant_id uuid NOT NULL REFERENCES teams_tenant_policy(tenant_id),
  conversation_id text NOT NULL,
  requested_by_hash text NOT NULL,
  action_kind text NOT NULL CHECK (action_kind IN ('notify_operations')),
  exact_payload jsonb NOT NULL,
  payload_hash text NOT NULL,
  state text NOT NULL DEFAULT 'draft'
    CHECK (state IN ('draft', 'approved', 'dispatching', 'dispatched', 'cancelled', 'expired', 'ambiguous', 'failed')),
  approved_by_hash text,
  approved_at timestamptz,
  expires_at timestamptz NOT NULL,
  dispatched_at timestamptz,
  external_id text,
  created_at timestamptz NOT NULL DEFAULT now(),
  CHECK (jsonb_typeof(exact_payload) = 'object')
);

CREATE INDEX IF NOT EXISTS teams_action_dispatch_idx
  ON teams_action_drafts (state, created_at)
  WHERE state = 'approved';

CREATE TABLE IF NOT EXISTS teams_rate_buckets (
  tenant_id uuid NOT NULL REFERENCES teams_tenant_policy(tenant_id),
  scope_hash text NOT NULL,
  minute_bucket timestamptz NOT NULL,
  request_count integer NOT NULL DEFAULT 1 CHECK (request_count > 0),
  PRIMARY KEY (tenant_id, scope_hash, minute_bucket)
);

CREATE TABLE IF NOT EXISTS bot_audit (
  id bigserial PRIMARY KEY,
  tenant_id uuid,
  event_class text NOT NULL,
  subject_hash text,
  object_id text,
  detail jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  CHECK (jsonb_typeof(detail) = 'object')
);

COMMIT;
Example output / evidence
TEAMS_SCHEMA_OK revision=teams-v1 activity_key=tenant+event state=tenant+conversation approvals=immutable operators=scoped

Checkpoint: State constraints exercised

psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f db/001_teams.sql && psql "$DATABASE_URL" -Atc "SELECT count(*) FROM bot_schema_revision"

Continue whenMigration commits once, rerun is controlled, duplicate platform events do not create duplicate work, cross-boundary state reads fail, and unsupported action states are rejected.

Stop whenStop if the runtime is database owner, event uniqueness lacks its security scope, approval content is mutable, or backup and downgrade are untested.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.
05

config

Implement authenticated Microsoft Teams ingress and bounded responses

caution

Implement the complete HTTP and worker path. Authenticate exact request bytes or channel tokens before parsing, bound the body, claim the event idempotently, acknowledge inside the platform deadline, then process moderation, permissions, state, attachments, and generated response from durable work. Validate all model output locally and call only reviewed response endpoints with mention, card, parse-mode, or rendering safeguards.

Why this step matters

Expose one HTTPS `/api/messages` endpoint behind an Azure Bot registration. Let the current Agents SDK validate bearer tokens and create the turn context; do not implement a home-grown JWT parser. After authentication, require channel `msteams`, normalize `channelData.tenant.id`, compare it with the deployment allowlist and active tenant policy, require activity, conversation, and actor identifiers, and persist the activity ID under the tenant key before responding. Bot Service activity handling must not wait on unbounded model, Graph, attachment, or business operations. The reference claims the activity durably, sends a typing indicator for bounded read-only generation, and keeps consequential work as a local draft. Long external actions are performed by the separately authorized dispatcher after approval, with stable idempotency and reconciliation instead of blind retries. The server must make authentication and acknowledgement independent from slow provider, file, or database work, while the worker remains able to finish, retry safely, or record an authoritative terminal state.

What to understand

Separate three permission planes. Application authentication allows the bot to exchange channel activities; it does not grant a user permission to access Microsoft Graph data. Delegated or application Graph scopes require a named purpose and Entra consent, while Teams resource-specific consent is declared in the app manifest and granted for a specific team or chat resource. The baseline requests neither Graph nor RSC access. If a future capability needs either, add the minimum permission, authorize the authenticated tenant and actor again, document admin consent and revocation, and never infer permission from a display name, card field, or tenant supplied by the browser.

Treat message text, quoted text, card input, invoke data, display names, and future attachment metadata as untrusted. Moderate bounded `/ask` input before generation and the complete response before posting. Strip model-produced mentions and links, render ordinary text rather than model-authored cards, and build approval cards from deterministic server data. Never allow the model to select an Adaptive Card verb, draft ID, tenant, dispatcher destination, Graph scope, or external action.

Reject attachments in the baseline rather than silently uploading them to a model. A future attachment profile must authorize the Teams or Graph resource, request the smallest delegated or RSC permission, validate hostname, redirect, MIME, file size, malware result, retention, and tenant scope, stream with a hard byte cap, and obtain separate consent before provider submission.

Apply shared tenant-and-actor minute buckets before message or invoke processing, plus tenant-wide model and dispatcher budgets. Respect Bot Service, Teams, Microsoft Graph, and downstream `Retry-After` responses where the exact operation is known safe to retry. Do not replay an activity already claimed, an invoke already approved, or an external write with an ambiguous outcome. Keep the ingress pool independent from the dispatcher.

Return stable public errors and request IDs. Restricted logs may include platform event ID hash, scope hash, policy revision, stage, provider request ID, status, duration, and usage, but never message text, token, signature, attachment body, generated response, or approved action body.

System changes

  • Creates authenticated ingress, durable work claiming, bounded response generation, platform reply calls, and safe terminal state transitions.

Syntax explained

authenticate raw request before JSON parsing
Binds the platform authenticity proof to the exact transmitted bytes and rejects spoofed events before allocating expensive work.
durable claim then immediate acknowledgement
Prevents duplicate processing while meeting the short platform response deadline and moving slow work to a worker.
local output validation
Constrains mentions, formatting, identifiers, actions, and length after model generation and before a platform response API call.
File src/teams.mjs
Configuration
import { createHash, createHmac, randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import {
  AgentApplication,
  CloudAdapter,
  MemoryStorage,
  authorizeJWT,
  loadAuthConfigFromEnv
} from "@microsoft/agents-hosting";
import express from "express";
import helmet from "helmet";
import OpenAI from "openai";
import pg from "pg";
import pino from "pino";

const CARD_CONTENT_TYPE = "application/vnd.microsoft.card.adaptive";
const INVOKE_NAME = "adaptiveCard/action";

export function normalizeTenantId(value) {
  const tenantId = String(value || "").trim().toLowerCase();
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(tenantId)) {
    throw new Error("invalid_tenant_id");
  }
  return tenantId;
}

export function stableHash(value, secret) {
  return createHmac("sha256", secret).update(String(value)).digest("hex");
}

export function boundedText(value, maximum) {
  const text = String(value || "").replace(/\u0000/g, "").trim();
  if (!text || text.length > maximum) throw new Error("message_out_of_bounds");
  return text;
}

export function exactPayloadHash(value) {
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}

export function safeTeamsText(value, maximum = 8000) {
  return String(value || "")
    .replace(/<at>[\s\S]*?<\/at>/gi, "[mention removed]")
    .replace(/https?:\/\/[^\s)]+/gi, "[link removed]")
    .slice(0, maximum);
}

export function approvalCard({ draftId, summary, expiresAt, state = "draft" }) {
  const actions = state === "draft"
    ? [
        {
          type: "Action.Execute",
          title: "Approve exact request",
          verb: "approveDraft",
          data: { draftId }
        },
        {
          type: "Action.Execute",
          title: "Cancel",
          verb: "cancelDraft",
          data: { draftId }
        }
      ]
    : [];
  return {
    type: "AdaptiveCard",
    version: "1.5",
    body: [
      { type: "TextBlock", weight: "Bolder", text: "Human approval required" },
      { type: "TextBlock", wrap: true, text: safeTeamsText(summary, 600) },
      {
        type: "FactSet",
        facts: [
          { title: "State", value: state },
          { title: "Expires", value: new Date(expiresAt).toISOString() },
          { title: "Draft", value: draftId }
        ]
      }
    ],
    actions
  };
}

export function invokeResponse(card, statusCode = 200) {
  return {
    type: "invokeResponse",
    value: {
      status: statusCode,
      body: {
        statusCode,
        type: CARD_CONTENT_TYPE,
        value: card
      }
    }
  };
}

function asCardAttachment(card) {
  return { contentType: CARD_CONTENT_TYPE, content: card };
}

function parseAllowlist(value) {
  const tenants = String(value || "")
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean)
    .map(normalizeTenantId);
  if (!tenants.length) throw new Error("missing_allowed_tenant_ids");
  return new Set(tenants);
}

function required(env, name) {
  const value = env[name];
  if (!value) throw new Error("missing_" + name.toLowerCase());
  return value;
}

function readActivityScope(activity, identitySecret) {
  if (activity.channelId !== "msteams") throw new Error("wrong_channel");
  const tenantId = normalizeTenantId(activity.channelData?.tenant?.id);
  const conversationId = String(activity.conversation?.id || "");
  const actorId = String(activity.from?.aadObjectId || activity.from?.id || "");
  const eventId = String(activity.id || "");
  if (!conversationId || !actorId || !eventId) throw new Error("missing_activity_scope");
  return {
    tenantId,
    conversationId,
    eventId,
    actorHash: stableHash(tenantId + ":" + actorId, identitySecret)
  };
}

export function createTeamsRuntime(env = process.env) {
  const identitySecret = required(env, "IDENTITY_HMAC_SECRET");
  const allowedTenants = parseAllowlist(required(env, "ALLOWED_TENANT_IDS"));
  const maximumMessage = Number(env.MAX_MESSAGE_CHARS || 6000);
  const maximumOutput = Number(env.MAX_OUTPUT_CHARS || 8000);
  const requestsPerMinute = Number(env.REQUESTS_PER_MINUTE || 12);
  const approvalTtl = Number(env.APPROVAL_TTL_MINUTES || 30);
  const stateTtl = Number(env.STATE_TTL_HOURS || 24);
  if (
    identitySecret.length < 32 ||
    !Number.isInteger(maximumMessage) ||
    maximumMessage < 256 ||
    maximumMessage > 16000 ||
    !Number.isInteger(maximumOutput) ||
    maximumOutput < 512 ||
    maximumOutput > 16000 ||
    !Number.isInteger(requestsPerMinute) ||
    requestsPerMinute < 1 ||
    requestsPerMinute > 120 ||
    !Number.isInteger(approvalTtl) ||
    approvalTtl < 5 ||
    approvalTtl > 120 ||
    !Number.isInteger(stateTtl) ||
    stateTtl < 1 ||
    stateTtl > 168
  ) {
    throw new Error("invalid_runtime_limits");
  }

  const pool = new pg.Pool({
    connectionString: required(env, "DATABASE_URL"),
    max: 12,
    statement_timeout: 12_000
  });
  const openai = new OpenAI({
    apiKey: required(env, "OPENAI_API_KEY"),
    timeout: 30_000,
    maxRetries: 1
  });
  const logger = pino({
    redact: {
      paths: [
        "req.headers.authorization",
        "*.text",
        "*.body",
        "*.content",
        "*.clientSecret",
        "*.ACTION_DISPATCH_TOKEN"
      ],
      censor: "[redacted]"
    }
  });

  async function requireTenant(client, tenantId) {
    if (!allowedTenants.has(tenantId)) throw new Error("tenant_not_allowlisted");
    const result = await client.query(
      "SELECT policy_revision FROM teams_tenant_policy WHERE tenant_id=$1 AND active=true",
      [tenantId]
    );
    if (!result.rowCount) throw new Error("tenant_not_active");
    return result.rows[0].policy_revision;
  }

  async function consumeRate(client, tenantId, actorHash) {
    const result = await client.query(
      "INSERT INTO teams_rate_buckets (tenant_id,scope_hash,minute_bucket,request_count) " +
        "VALUES ($1,$2,date_trunc('minute',now()),1) " +
        "ON CONFLICT (tenant_id,scope_hash,minute_bucket) DO UPDATE " +
        "SET request_count=teams_rate_buckets.request_count+1 " +
        "WHERE teams_rate_buckets.request_count < $3 RETURNING request_count",
      [tenantId, actorHash, requestsPerMinute]
    );
    if (!result.rowCount) throw new Error("rate_limited");
  }

  async function claimEvent(client, scope, activity, bodyText) {
    const inserted = await client.query(
      "INSERT INTO teams_events " +
        "(tenant_id,event_id,conversation_id,actor_hash,activity_type,body_hash) " +
        "VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING RETURNING event_id",
      [
        scope.tenantId,
        scope.eventId,
        scope.conversationId,
        scope.actorHash,
        String(activity.type || "unknown").slice(0, 40),
        exactPayloadHash(bodyText)
      ]
    );
    return inserted.rowCount === 1;
  }

  async function audit(client, scope, eventClass, objectId, detail = {}) {
    await client.query(
      "INSERT INTO bot_audit (tenant_id,event_class,subject_hash,object_id,detail) " +
        "VALUES ($1,$2,$3,$4,$5::jsonb)",
      [
        scope.tenantId,
        eventClass,
        scope.actorHash,
        objectId || null,
        JSON.stringify(detail)
      ]
    );
  }

  async function recordConversationState(client, scope, state) {
    await client.query(
      "INSERT INTO teams_conversation_state " +
        "(tenant_id,conversation_id,state_json,expires_at) " +
        "VALUES ($1,$2,$3::jsonb,now()+($4||' hours')::interval) " +
        "ON CONFLICT (tenant_id,conversation_id) DO UPDATE SET " +
        "state_json=excluded.state_json,expires_at=excluded.expires_at,updated_at=now()",
      [
        scope.tenantId,
        scope.conversationId,
        JSON.stringify(state),
        String(stateTtl)
      ]
    );
  }

  async function createDraft(client, scope, summary) {
    const id = randomUUID();
    const exactPayload = {
      kind: "notify_operations",
      tenantId: scope.tenantId,
      conversationId: scope.conversationId,
      summary: safeTeamsText(summary, 600)
    };
    const payloadHash = exactPayloadHash(exactPayload);
    const inserted = await client.query(
      "INSERT INTO teams_action_drafts " +
        "(id,tenant_id,conversation_id,requested_by_hash,action_kind,exact_payload,payload_hash,expires_at) " +
        "VALUES ($1,$2,$3,$4,'notify_operations',$5::jsonb,$6,now()+($7||' minutes')::interval) " +
        "RETURNING id,exact_payload,payload_hash,state,expires_at",
      [
        id,
        scope.tenantId,
        scope.conversationId,
        scope.actorHash,
        JSON.stringify(exactPayload),
        payloadHash,
        String(approvalTtl)
      ]
    );
    await audit(client, scope, "action_draft_created", id, {
      actionKind: "notify_operations",
      payloadHash
    });
    return inserted.rows[0];
  }

  async function approveOrCancel(client, scope, verb, draftId) {
    await client.query("BEGIN");
    try {
      await requireTenant(client, scope.tenantId);
      const operator = await client.query(
        "SELECT 1 FROM teams_operators " +
          "WHERE tenant_id=$1 AND actor_hash=$2 AND role='approver' AND enabled=true",
        [scope.tenantId, scope.actorHash]
      );
      if (!operator.rowCount) throw new Error("approver_required");
      const result = await client.query(
        "SELECT * FROM teams_action_drafts " +
          "WHERE id=$1 AND tenant_id=$2 AND conversation_id=$3 FOR UPDATE",
        [draftId, scope.tenantId, scope.conversationId]
      );
      if (!result.rowCount) throw new Error("draft_not_found");
      const draft = result.rows[0];
      if (draft.state !== "draft") throw new Error("draft_not_pending");
      if (Date.parse(draft.expires_at) <= Date.now()) {
        await client.query(
          "UPDATE teams_action_drafts SET state='expired' WHERE id=$1",
          [draftId]
        );
        throw new Error("draft_expired");
      }
      if (exactPayloadHash(draft.exact_payload) !== draft.payload_hash) {
        throw new Error("draft_payload_changed");
      }
      const next = verb === "approveDraft" ? "approved" : "cancelled";
      await client.query(
        "UPDATE teams_action_drafts SET state=$2,approved_by_hash=$3," +
          "approved_at=CASE WHEN $2='approved' THEN now() ELSE NULL END WHERE id=$1",
        [draftId, next, scope.actorHash]
      );
      await audit(client, scope, "action_draft_" + next, draftId, {
        payloadHash: draft.payload_hash
      });
      await client.query("COMMIT");
      return { ...draft, state: next };
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    }
  }

  async function moderateText(text) {
    const result = await openai.moderations.create({
      model: "omni-moderation-latest",
      input: text
    });
    return !result.results.some((item) => item.flagged);
  }

  async function answerQuestion(text) {
    if (!(await moderateText(text))) return "I cannot help with that request.";
    const result = await openai.responses.create({
      model: env.OPENAI_MODEL || "gpt-5.4-nano",
      input: [
        {
          role: "developer",
          content:
            "Answer as a concise Teams assistant. Do not claim to perform actions. " +
            "Do not emit mentions, executable instructions, secrets, or external links. " +
            "Route consequential work to a human-approved draft."
        },
        { role: "user", content: text }
      ],
      max_output_tokens: 900,
      store: false
    });
    const output = safeTeamsText(result.output_text, maximumOutput);
    if (!output || !(await moderateText(output))) {
      return "I could not produce a safe response.";
    }
    return output;
  }

  async function withScope(context, operation) {
    const scope = readActivityScope(context.activity, identitySecret);
    const client = await pool.connect();
    try {
      await requireTenant(client, scope.tenantId);
      return await operation(client, scope);
    } finally {
      client.release();
    }
  }

  const application = new AgentApplication({ storage: new MemoryStorage() });

  application.onConversationUpdate("membersAdded", async (context) => {
    await withScope(context, async (client, scope) => {
      const isNew = await claimEvent(client, scope, context.activity, "membersAdded");
      if (!isNew) return;
      await context.sendActivity(
        "OneLiners Teams assistant is ready. Use /help, /ask <question>, or " +
          "/request <exact operations notification>. Requests never execute without approval."
      );
      await client.query(
        "UPDATE teams_events SET status='completed',completed_at=now() " +
          "WHERE tenant_id=$1 AND event_id=$2",
        [scope.tenantId, scope.eventId]
      );
    });
  });

  application.onActivity("message", async (context) => {
    await withScope(context, async (client, scope) => {
      await consumeRate(client, scope.tenantId, scope.actorHash);
      const body = boundedText(context.activity.text, maximumMessage);
      const isNew = await claimEvent(client, scope, context.activity, body);
      if (!isNew) return;
      if (context.activity.attachments?.length) {
        await context.sendActivity(
          "Attachments are disabled in this baseline. Paste bounded, non-sensitive text instead."
        );
        await client.query(
          "UPDATE teams_events SET status='refused',completed_at=now() " +
            "WHERE tenant_id=$1 AND event_id=$2",
          [scope.tenantId, scope.eventId]
        );
        return;
      }

      let responseActivity;
      if (body === "/help") {
        responseActivity = await context.sendActivity(
          "Use /ask <question> for a read-only answer. Use /request <exact summary> " +
            "to create an immutable draft that a configured tenant approver must review."
        );
      } else if (body.startsWith("/request ")) {
        const summary = boundedText(body.slice(9), 600);
        const draft = await createDraft(client, scope, summary);
        responseActivity = await context.sendActivity({
          attachments: [
            asCardAttachment(
              approvalCard({
                draftId: draft.id,
                summary: draft.exact_payload.summary,
                expiresAt: draft.expires_at
              })
            )
          ]
        });
      } else if (body.startsWith("/ask ")) {
        const question = boundedText(body.slice(5), maximumMessage);
        await context.sendActivity({ type: "typing" });
        responseActivity = await context.sendActivity(await answerQuestion(question));
        await recordConversationState(client, scope, {
          lastCommand: "ask",
          responseClass: "read_only",
          updatedAt: new Date().toISOString()
        });
      } else {
        responseActivity = await context.sendActivity(
          "Unsupported command. Use /help, /ask <question>, or /request <exact summary>."
        );
      }
      await client.query(
        "UPDATE teams_events SET status='completed',response_activity_id=$3,completed_at=now() " +
          "WHERE tenant_id=$1 AND event_id=$2",
        [scope.tenantId, scope.eventId, String(responseActivity?.id || "")]
      );
    });
  });

  application.onActivity("invoke", async (context) => {
    await withScope(context, async (client, scope) => {
      if (context.activity.name !== INVOKE_NAME) throw new Error("unsupported_invoke");
      await consumeRate(client, scope.tenantId, scope.actorHash);
      const isNew = await claimEvent(
        client,
        scope,
        context.activity,
        JSON.stringify(context.activity.value || {})
      );
      if (!isNew) {
        await context.sendActivity(
          invokeResponse({
            type: "AdaptiveCard",
            version: "1.5",
            body: [
              {
                type: "TextBlock",
                weight: "Bolder",
                text: "This action was already processed."
              },
              {
                type: "TextBlock",
                wrap: true,
                text: "Refresh the conversation before taking another action."
              }
            ],
            actions: []
          })
        );
        return;
      }
      const action = context.activity.value?.action;
      const verb = String(action?.verb || "");
      const draftId = String(action?.data?.draftId || "");
      if (!["approveDraft", "cancelDraft"].includes(verb)) {
        throw new Error("unsupported_action_verb");
      }
      if (!/^[0-9a-f-]{36}$/i.test(draftId)) throw new Error("invalid_draft_id");
      const draft = await approveOrCancel(client, scope, verb, draftId);
      await context.sendActivity(
        invokeResponse(
          approvalCard({
            draftId,
            summary: draft.exact_payload.summary,
            expiresAt: draft.expires_at,
            state: draft.state
          })
        )
      );
      await client.query(
        "UPDATE teams_events SET status='completed',completed_at=now() " +
          "WHERE tenant_id=$1 AND event_id=$2",
        [scope.tenantId, scope.eventId]
      );
    });
  });

  async function dispatchOne() {
    const client = await pool.connect();
    try {
      await client.query("BEGIN");
      const result = await client.query(
        "SELECT * FROM teams_action_drafts WHERE state='approved' " +
          "AND expires_at>now() ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1"
      );
      if (!result.rowCount) {
        await client.query("ROLLBACK");
        return false;
      }
      const draft = result.rows[0];
      if (exactPayloadHash(draft.exact_payload) !== draft.payload_hash) {
        throw new Error("approved_payload_changed");
      }
      await client.query(
        "UPDATE teams_action_drafts SET state='dispatching' WHERE id=$1",
        [draft.id]
      );
      await client.query("COMMIT");

      let response;
      try {
        response = await fetch(required(env, "ACTION_DISPATCH_URL"), {
          method: "POST",
          redirect: "error",
          headers: {
            authorization: "Bearer " + required(env, "ACTION_DISPATCH_TOKEN"),
            "content-type": "application/json",
            "idempotency-key": draft.id
          },
          body: JSON.stringify({
            draftId: draft.id,
            payloadHash: draft.payload_hash,
            action: draft.exact_payload
          }),
          signal: AbortSignal.timeout(10_000)
        });
      } catch {
        await pool.query(
          "UPDATE teams_action_drafts SET state='ambiguous' WHERE id=$1 AND state='dispatching'",
          [draft.id]
        );
        return true;
      }
      if (!response.ok) {
        const state = response.status >= 500 ? "ambiguous" : "failed";
        await pool.query(
          "UPDATE teams_action_drafts SET state=$2 WHERE id=$1 AND state='dispatching'",
          [draft.id, state]
        );
        return true;
      }
      const externalId = response.headers.get("x-operation-id");
      await pool.query(
        "UPDATE teams_action_drafts SET state='dispatched',external_id=$2,dispatched_at=now() " +
          "WHERE id=$1 AND state='dispatching'",
        [draft.id, externalId]
      );
      return true;
    } catch (error) {
      await client.query("ROLLBACK").catch(() => {});
      throw error;
    } finally {
      client.release();
    }
  }

  return { application, dispatchOne, logger, pool };
}

export async function startTeamsServer(env = process.env) {
  const authConfig = loadAuthConfigFromEnv();
  const adapter = new CloudAdapter(authConfig);
  const runtime = createTeamsRuntime(env);
  const server = express();
  server.disable("x-powered-by");
  server.use(helmet({ contentSecurityPolicy: false }));
  server.get("/healthz", (_request, response) => response.status(200).send("ok"));
  server.post(
    "/api/messages",
    authorizeJWT(authConfig),
    express.json({ limit: "96kb", strict: true, type: "application/json" }),
    async (request, response, next) => {
      try {
        await adapter.process(request, response, async (context) => {
          await runtime.application.run(context);
        });
      } catch (error) {
        next(error);
      }
    }
  );
  server.use((error, _request, response, _next) => {
    runtime.logger.warn({ errorClass: String(error?.message || "teams_error") }, "teams_request_failed");
    if (!response.headersSent) response.status(400).json({ error: "request_failed" });
  });
  server.listen(Number(env.PORT || 3978), "0.0.0.0");
}

export async function startTeamsDispatcher(env = process.env) {
  const runtime = createTeamsRuntime(env);
  for (;;) {
    const claimed = await runtime.dispatchOne();
    await new Promise((resolve) => setTimeout(resolve, claimed ? 100 : 1000));
  }
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  if (process.argv.includes("--worker")) await startTeamsDispatcher();
  else await startTeamsServer();
}
Example output / evidence
tenant=tenant-hash activity=message event=new auth=cloud_adapter command=ask moderation=passed response=completed

Checkpoint: End-to-end read-only interaction succeeds

Continue whenA signed or authenticated synthetic event is acknowledged on time, processed once, moderated, permission-checked, scoped correctly, and updated through the reviewed response endpoint without a consequential write.

Stop whenStop if parsing precedes authentication, acknowledgement misses the platform deadline, a duplicate creates work, content bypasses moderation, or model output controls an API method or destination.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.
06

decision

Enforce moderation, permissions, and attachment policy before generation

read-only

Treat message text, quoted text, card input, invoke data, display names, and future attachment metadata as untrusted. Moderate bounded `/ask` input before generation and the complete response before posting. Strip model-produced mentions and links, render ordinary text rather than model-authored cards, and build approval cards from deterministic server data. Never allow the model to select an Adaptive Card verb, draft ID, tenant, dispatcher destination, Graph scope, or external action. Separate three permission planes. Application authentication allows the bot to exchange channel activities; it does not grant a user permission to access Microsoft Graph data. Delegated or application Graph scopes require a named purpose and Entra consent, while Teams resource-specific consent is declared in the app manifest and granted for a specific team or chat resource. The baseline requests neither Graph nor RSC access. If a future capability needs either, add the minimum permission, authorize the authenticated tenant and actor again, document admin consent and revocation, and never infer permission from a display name, card field, or tenant supplied by the browser. Reject attachments in the baseline rather than silently uploading them to a model. A future attachment profile must authorize the Teams or Graph resource, request the smallest delegated or RSC permission, validate hostname, redirect, MIME, file size, malware result, retention, and tenant scope, stream with a hard byte cap, and obtain separate consent before provider submission. Perform deterministic size, type, scope, command, URL, and policy checks before provider submission. Moderate bounded user text and the final response where policy requires it. Unsupported, uncertain, sensitive, or consequential requests receive a neutral refusal or local draft; they never widen permissions or call an administrative API.

Why this step matters

Channel presence is not authorization. A user can invoke a bot in a context where the application lacks permission, the user lacks the required role, the content is unsafe, or an attachment exceeds the reviewed purpose. Fail-closed checks reduce cost and prevent a fluent response from obscuring a missing authorization decision.

What to understand

Separate three permission planes. Application authentication allows the bot to exchange channel activities; it does not grant a user permission to access Microsoft Graph data. Delegated or application Graph scopes require a named purpose and Entra consent, while Teams resource-specific consent is declared in the app manifest and granted for a specific team or chat resource. The baseline requests neither Graph nor RSC access. If a future capability needs either, add the minimum permission, authorize the authenticated tenant and actor again, document admin consent and revocation, and never infer permission from a display name, card field, or tenant supplied by the browser.

Treat message text, quoted text, card input, invoke data, display names, and future attachment metadata as untrusted. Moderate bounded `/ask` input before generation and the complete response before posting. Strip model-produced mentions and links, render ordinary text rather than model-authored cards, and build approval cards from deterministic server data. Never allow the model to select an Adaptive Card verb, draft ID, tenant, dispatcher destination, Graph scope, or external action.

Reject attachments in the baseline rather than silently uploading them to a model. A future attachment profile must authorize the Teams or Graph resource, request the smallest delegated or RSC permission, validate hostname, redirect, MIME, file size, malware result, retention, and tenant scope, stream with a hard byte cap, and obtain separate consent before provider submission.

Normalize text only after authenticity verification. Strip control characters, cap Unicode length and attachment metadata, disable broad mentions or unsafe rendering, and preserve a content hash rather than raw content in ordinary audit.

System changes

  • Creates authorization and moderation decision evidence; it does not change channel membership, roles, messages, attachments, users, or external systems.
Example output / evidence
input=allowed output=allowed mentions_removed=1 links_removed=1 attachments=disabled model_cards=0

Checkpoint: Policy matrix passes

Continue whenAllowed synthetic requests proceed, missing app or actor permissions fail, unsafe content receives the reviewed neutral result, oversized or unsupported attachments fail before download or model submission, and consequential requests become drafts.

Stop whenStop if membership alone grants privilege, attachment URL is unrestricted, unsafe output reaches a public channel, or moderation can approve a consequential action.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.

Alternatives

  • Disable attachments and generated content for the first cohort; support only strict text commands and deterministic responses.
07

decision

Exercise idempotency, scoped state, throttled updates, and retry reconciliation

caution

Use `(tenant_id, event_id)` for delivery idempotency and `(tenant_id, conversation_id)` for conversation state. Hash the authenticated actor ID with a deployment secret and tenant prefix. Persist only command class and response class in expiring state, not raw messages. Approval and operator rows are tenant-scoped; the dispatcher re-hashes exact payload JSON and never accepts a replacement body from the card action. Apply shared tenant-and-actor minute buckets before message or invoke processing, plus tenant-wide model and dispatcher budgets. Respect Bot Service, Teams, Microsoft Graph, and downstream `Retry-After` responses where the exact operation is known safe to retry. Do not replay an activity already claimed, an invoke already approved, or an external write with an ambiguous outcome. Keep the ingress pool independent from the dispatcher. Simulate duplicate delivery, worker restart, provider timeout, platform 429, platform 5xx, edit collision, and ambiguous response. Honor explicit retry guidance with bounded jitter, throttle visible edits, and keep the durable work item blocked when the platform may have accepted a write but the response was lost.

Why this step matters

At-least-once delivery and HTTP ambiguity are ordinary channel behavior. Correctness depends on a scoped event key, state revision, response identity, and authoritative reconciliation rather than optimistic retries. Visible edit throttling protects users and platform quotas while preserving a useful acknowledgement.

What to understand

Bot Service activity handling must not wait on unbounded model, Graph, attachment, or business operations. The reference claims the activity durably, sends a typing indicator for bounded read-only generation, and keeps consequential work as a local draft. Long external actions are performed by the separately authorized dispatcher after approval, with stable idempotency and reconciliation instead of blind retries.

Apply shared tenant-and-actor minute buckets before message or invoke processing, plus tenant-wide model and dispatcher budgets. Respect Bot Service, Teams, Microsoft Graph, and downstream `Retry-After` responses where the exact operation is known safe to retry. Do not replay an activity already claimed, an invoke already approved, or an external write with an ambiguous outcome. Keep the ingress pool independent from the dispatcher.

Use destination request IDs or fetch the original response where the platform supports it. If outcome cannot be queried, require operator reconciliation and never change channel, chat, tenant, or destination as an automatic fallback.

State updates use compare-and-set or row locks so two workers cannot overwrite conversation revision, approval, or final response. Expired tokens become a clear terminal state rather than an attempt to post elsewhere.

System changes

  • Creates synthetic duplicate, retry, throttling, and reconciliation records; no real channel or user is contacted.
Example output / evidence
tenant_a_events=4 tenant_b_events=2 duplicate_runs=0 cross_tenant_reads=0 conversation_payload=text-free ttl_hours=24

Checkpoint: Delivery failure matrix accepted

Continue whenDuplicate events run once, state never crosses scope, edits respect cadence, explicit rate limits are honored, deterministic failures retry within budget, and ambiguous writes block.

Stop whenStop if timeout blindly resends, state crosses tenant/guild/chat/conversation, visible edits flood the platform, or retries outlive the platform response token.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.
08

decision

Require exact-content human approval before consequential writes

caution

The `/request` command creates an immutable `notify_operations` draft with exact tenant, conversation, summary, payload hash, expiry, and requesting actor hash. A deterministic Adaptive Card uses `Action.Execute` only to send `approveDraft` or `cancelDraft` plus the draft ID. The server authenticates the invoke activity, reloads the tenant-scoped draft under a lock, requires an enabled approver, re-hashes exact payload, and records approval. A separate dispatcher identity posts the fixed payload to one allowlisted internal service with an idempotency key. Editing, expiry, cross-tenant use, non-operator use, or ambiguous dispatch blocks execution. The review surface shows actor and scope, original request hash, proposed operation, exact destination, exact payload, reason, risk, policy revision, expiry, and expected effect. Approval uses a short-lived privileged identity and content hash. Editing creates a new draft. A separate dispatcher performs only the approved operation with idempotency and records the platform request ID.

Why this step matters

A user request and a model suggestion do not authorize moderation, messaging, membership, administrative, file, or business writes. Exact-content approval turns a conversational proposal into accountable operator intent, while separate dispatch prevents the approval session from becoming an unrestricted platform credential.

What to understand

Reject, edit-as-new, approve, dispatch, reconcile-as-sent, and reconcile-as-not-sent are separate audit events. Approval expires when scope, payload, destination, policy, actor authorization, or platform permission changes.

The dispatcher uses a least-privilege destination-specific credential and an allowlisted API method. It cannot create new drafts, broaden scope, or substitute a different target.

Ambiguous dispatch is looked up by draft or platform request ID. Do not retry until the authoritative outcome is known; if lookup is unavailable, keep dispatch manual.

System changes

  • Creates synthetic local draft, approval, dispatch, and reconciliation state under test identities; production writes remain disabled.
Example output / evidence
draft=7d5c tenant=tenant-hash state=draft card_verb=approveDraft approver=allowed payload_match=true state=approved dispatch_count=0

Checkpoint: Approval separation demonstrated

Continue whenThe requester and model cannot approve, the approver cannot silently change payload, the dispatcher cannot invent an operation, one approved draft dispatches once, and ambiguity blocks.

Stop whenStop if one credential can request, approve, and dispatch; viewing implies approval; content changes afterward; or an unapproved platform write occurs.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.
09

config

Run deterministic Microsoft Teams contract and abuse tests

read-only

Add tests that require no production credential or network. Cover authenticity failure, oversized body, malformed event, duplicate ID, wrong security scope, missing permission, unsafe input and output, attachment rejection, state conflict, approval immutability, throttled edit, rate guidance, ambiguous result, and teardown guards. Fake platform and provider adapters must record method, target, idempotency, and content hash.

Why this step matters

Deterministic tests protect platform and authorization boundaries independently from model quality. Live evaluations can measure helpfulness and moderation recall, but they must not replace proof that spoofed, duplicate, cross-scope, unsafe, unapproved, or ambiguous events fail before an external effect.

What to understand

Expose one HTTPS `/api/messages` endpoint behind an Azure Bot registration. Let the current Agents SDK validate bearer tokens and create the turn context; do not implement a home-grown JWT parser. After authentication, require channel `msteams`, normalize `channelData.tenant.id`, compare it with the deployment allowlist and active tenant policy, require activity, conversation, and actor identifiers, and persist the activity ID under the tenant key before responding.

Use `(tenant_id, event_id)` for delivery idempotency and `(tenant_id, conversation_id)` for conversation state. Hash the authenticated actor ID with a deployment secret and tenant prefix. Persist only command class and response class in expiring state, not raw messages. Approval and operator rows are tenant-scoped; the dispatcher re-hashes exact payload JSON and never accepts a replacement body from the card action.

Treat message text, quoted text, card input, invoke data, display names, and future attachment metadata as untrusted. Moderate bounded `/ask` input before generation and the complete response before posting. Strip model-produced mentions and links, render ordinary text rather than model-authored cards, and build approval cards from deterministic server data. Never allow the model to select an Adaptive Card verb, draft ID, tenant, dispatcher destination, Graph scope, or external action.

Use synthetic identifiers, messages, attachments, permissions, cards, and tenant/guild/chat contexts. A test fixture must never contain a real token, signature key, customer conversation, employee identity, or private file.

System changes

  • Creates local test fixtures and fake adapter records; it does not call the platform, provider, identity service, database, or external destination.

Syntax explained

fake platform adapter
Records exact response or dispatch intent without using a real channel, allowing duplicate, rate, and ambiguity assertions.
fixed clock and deterministic IDs
Makes signature windows, token expiry, state retention, edit cadence, and approval expiry repeatable.
File test/teams.test.mjs
Configuration
import assert from "node:assert/strict";
import test from "node:test";
import {
  approvalCard,
  boundedText,
  exactPayloadHash,
  invokeResponse,
  normalizeTenantId,
  safeTeamsText,
  stableHash
} from "../src/teams.mjs";

test("tenant identifiers are normalized and bounded", () => {
  assert.equal(
    normalizeTenantId("11111111-1111-4111-8111-111111111111"),
    "11111111-1111-4111-8111-111111111111"
  );
  assert.throws(() => normalizeTenantId("common"), /invalid_tenant_id/);
  assert.throws(() => boundedText("x".repeat(601), 600), /message_out_of_bounds/);
});

test("identity hashes remain tenant-scoped", () => {
  const secret = "test-secret-with-at-least-thirty-two-bytes";
  const first = stableHash("tenant-a:actor-1", secret);
  const second = stableHash("tenant-b:actor-1", secret);
  assert.equal(first.length, 64);
  assert.notEqual(first, second);
});

test("Teams text removes mentions and links", () => {
  assert.equal(
    safeTeamsText("<at>Admin</at> see https://example.com/runbook"),
    "[mention removed] see [link removed]"
  );
});

test("approval card binds Action.Execute to one immutable draft", () => {
  const card = approvalCard({
    draftId: "11111111-1111-4111-8111-111111111111",
    summary: "Notify operations about the reviewed incident.",
    expiresAt: "2026-07-29T12:30:00.000Z"
  });
  assert.equal(card.version, "1.5");
  assert.equal(card.actions[0].type, "Action.Execute");
  assert.equal(card.actions[0].verb, "approveDraft");
  assert.equal(
    card.actions[0].data.draftId,
    "11111111-1111-4111-8111-111111111111"
  );
  assert.equal(card.actions[1].verb, "cancelDraft");
});

test("invoke response replaces the card without executing client data", () => {
  const card = approvalCard({
    draftId: "11111111-1111-4111-8111-111111111111",
    summary: "Reviewed",
    expiresAt: "2026-07-29T12:30:00.000Z",
    state: "approved"
  });
  const response = invokeResponse(card);
  assert.equal(response.type, "invokeResponse");
  assert.equal(response.value.status, 200);
  assert.equal(response.value.body.type, "application/vnd.microsoft.card.adaptive");
  assert.equal(response.value.body.value.actions.length, 0);
});

test("exact payload hashes change with exact approved content", () => {
  const first = exactPayloadHash({ kind: "notify_operations", summary: "A" });
  const second = exactPayloadHash({ kind: "notify_operations", summary: "B" });
  assert.equal(first.length, 64);
  assert.notEqual(first, second);
});
Example output / evidence
TAP version 13
ok 1 - tenant identifiers
ok 2 - identity isolation
ok 3 - safe text
ok 4 - Action.Execute card
ok 5 - invoke response
ok 6 - exact payload hash
# pass 6

Checkpoint: Security contract suite is green

npm test

Continue whenAll deterministic tests pass without network or secrets, every fake external call is expected, and unapproved write count remains zero.

Stop whenStop if a test depends on generated prose, uses real data, opens an uncontrolled socket, or an invalid event reaches dispatch.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.
10

instruction

Add content-free observability and operational alerts

read-only

Measure Agents SDK authentication rejection, tenant denial, activity type and latency, duplicate activity rate, conversation-state expiry, moderation class, model status and usage, Adaptive Card verb class, approver denial, draft age, payload mismatch, dispatcher state, ambiguous writes, admin-consent drift, app-manifest revision, service health, backup age, and isolated restore. Use tenant- and actor-scoped HMAC identifiers; never export authorization headers, client secrets, tokens, raw message text, card input, conversation IDs, attachment URLs, Graph objects, or exact external payloads.

Why this step matters

A production bot needs evidence about authentication, acknowledgement, queues, moderation, provider work, platform rate limits, approval, dispatch, state, and retention. Telemetry is also an easy route for messages and identifiers to escape, so signals must be low-cardinality and content-free by construction.

What to understand

Measure authenticated and rejected events, acknowledgement latency, duplicate rate, queue age, provider latency and usage, moderation decisions, permission failures, attachment classes, edit count, 429 wait, approval age, dispatch ambiguity, database saturation, backup age, and restore result.

Use controlled attributes such as route template, event type, deployment revision, policy revision, result class, and retry class. Never label metrics with raw actor, tenant, guild, chat, channel, conversation, message, attachment, prompt, response, card, token, or action body.

Plant canary secrets and content in synthetic events and prove they do not appear in logs, traces, metrics, dashboards, alerts, backup metadata, or deployment output.

System changes

  • Creates dashboards, alerts, and exporter policy; it does not change bot decisions, platform state, or user content.
Example output / evidence
TEAMS_OTEL_OK auth_denied=3 tenant_denied=2 duplicates=11 approval_denied=4 ambiguous=0 content_attributes=0 token_attributes=0

Checkpoint: Observability privacy gate passes

Continue whenSynthetic activity produces stage, latency, queue, moderation, rate, approval, and dispatch signals while canary content and credentials appear nowhere in telemetry.

Stop whenStop if request bodies, headers, platform tokens, message text, attachment names, card values, model output, or stable user identifiers are exported.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.
11

config

Deploy Microsoft Teams with immutable revisions and zero-downtime state

caution

Register the Entra application and Azure Bot for the chosen single-tenant or explicitly reviewed multitenant model, connect the Teams channel, package the Teams app manifest, and publish through the intended organization policy. Use a client secret only for local development. Production uses a matching user-assigned managed identity or federated identity credential. Deploy ingress replicas and the single dispatcher against one PostgreSQL state, keep the Bot messaging endpoint stable, verify a pilot tenant before broader availability, and obtain administrator consent only for the exact new Graph or RSC permissions introduced by a reviewed revision.

Why this step matters

Register the Entra application and Azure Bot for the chosen single-tenant or explicitly reviewed multitenant model, connect the Teams channel, package the Teams app manifest, and publish through the intended organization policy. Use a client secret only for local development. Production uses a matching user-assigned managed identity or federated identity credential. Deploy ingress replicas and the single dispatcher against one PostgreSQL state, keep the Bot messaging endpoint stable, verify a pilot tenant before broader availability, and obtain administrator consent only for the exact new Graph or RSC permissions introduced by a reviewed revision. Immutable images, shared durable idempotency, health checks, bounded resources, restricted ingress, and revision-aware workers make rollback meaningful while overlapping instances cannot double-process an event.

What to understand

Expose one HTTPS `/api/messages` endpoint behind an Azure Bot registration. Let the current Agents SDK validate bearer tokens and create the turn context; do not implement a home-grown JWT parser. After authentication, require channel `msteams`, normalize `channelData.tenant.id`, compare it with the deployment allowlist and active tenant policy, require activity, conversation, and actor identifiers, and persist the activity ID under the tenant key before responding.

Use `(tenant_id, event_id)` for delivery idempotency and `(tenant_id, conversation_id)` for conversation state. Hash the authenticated actor ID with a deployment secret and tenant prefix. Persist only command class and response class in expiring state, not raw messages. Approval and operator rows are tenant-scoped; the dispatcher re-hashes exact payload JSON and never accepts a replacement body from the card action.

Run as non-root with read-only filesystem, bounded temporary storage, no host mounts, explicit CPU/memory/process/connection limits, TLS to dependencies, and egress only to reviewed platform, provider, database, telemetry, and dispatch endpoints.

Canary with synthetic authentication failures, duplicates, moderation, permission denial, attachments, provider delay, rate limit, approval, dispatch ambiguity, worker termination, and old/new revision overlap before real traffic.

System changes

  • Creates deployment, service, health, secret reference, and revision state; production routing remains under release approval.

Syntax explained

immutable image digest
Binds deployment and rollback to the exact reviewed runtime and lockfile rather than a mutable tag.
shared idempotency database
Lets overlapping revisions claim a platform event once during rolling or blue-green deployment.
File deploy/teams.yaml
Configuration
apiVersion: v1
kind: ServiceAccount
metadata:
  name: teams-assistant
  annotations:
    azure.workload.identity/client-id: "22222222-2222-2222-2222-222222222222"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: teams-assistant-ingress
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels:
      app: teams-assistant
      role: ingress
  template:
    metadata:
      labels:
        app: teams-assistant
        role: ingress
        azure.workload.identity/use: "true"
    spec:
      serviceAccountName: teams-assistant
      automountServiceAccountToken: false
      containers:
        - name: ingress
          image: registry.example/teams-assistant@sha256:replace-reviewed-digest
          args: ["node", "src/teams.mjs"]
          ports:
            - containerPort: 3978
          envFrom:
            - configMapRef:
                name: teams-assistant-policy
            - secretRef:
                name: teams-assistant-secrets
          readinessProbe:
            httpGet:
              path: /healthz
              port: 3978
            initialDelaySeconds: 3
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /healthz
              port: 3978
            initialDelaySeconds: 15
            periodSeconds: 10
          resources:
            requests:
              cpu: 100m
              memory: 160Mi
            limits:
              cpu: "1"
              memory: 512Mi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            capabilities:
              drop: ["ALL"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: teams-assistant-dispatcher
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: teams-assistant
      role: dispatcher
  template:
    metadata:
      labels:
        app: teams-assistant
        role: dispatcher
        azure.workload.identity/use: "true"
    spec:
      serviceAccountName: teams-assistant
      automountServiceAccountToken: false
      containers:
        - name: dispatcher
          image: registry.example/teams-assistant@sha256:replace-reviewed-digest
          args: ["node", "src/teams.mjs", "--worker"]
          envFrom:
            - configMapRef:
                name: teams-assistant-policy
            - secretRef:
                name: teams-dispatcher-secrets
          resources:
            requests:
              cpu: 50m
              memory: 128Mi
            limits:
              cpu: "0.5"
              memory: 384Mi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            capabilities:
              drop: ["ALL"]
---
apiVersion: v1
kind: Service
metadata:
  name: teams-assistant
spec:
  selector:
    app: teams-assistant
    role: ingress
  ports:
    - name: https-upstream
      port: 80
      targetPort: 3978
Example output / evidence
TEAMS_CANARY_OK azure_bot=ready endpoint=stable managed_identity=valid pilot_tenant=passed manifest_digest=recorded consent_delta=none

Checkpoint: Deployment canary accepted

Continue whenNew and old revisions overlap without duplicate work, acknowledgement meets deadline, state remains scoped, unapproved writes remain zero, health and telemetry pass, and rollback is rehearsed.

Stop whenStop if an image is unpinned, secrets appear in manifests, ingress is public without platform authentication, egress is broad, or overlap duplicates a response.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.
12

command

Back up state and export a privacy-minimized audit ledger

caution

Back up tenant policies, idempotency rows, expiring conversation metadata, operator assignments, drafts, approvals, terminal dispatcher identifiers, and sanitized audit. Do not back up chat text, card input, bearer tokens, client secrets, Graph access tokens, or attachment bytes. Restore into an isolated environment with Bot Service, Teams, Graph, model, and dispatcher egress blocked; expire or quarantine pending drafts and never dispatch historical approvals automatically.

Why this step matters

Back up tenant policies, idempotency rows, expiring conversation metadata, operator assignments, drafts, approvals, terminal dispatcher identifiers, and sanitized audit. Do not back up chat text, card input, bearer tokens, client secrets, Graph access tokens, or attachment bytes. Restore into an isolated environment with Bot Service, Teams, Graph, model, and dispatcher egress blocked; expire or quarantine pending drafts and never dispatch historical approvals automatically. A database backup supports service recovery; a minimized audit export supports handoff and review. Neither should contain raw conversations, tokens, attachments, model output, or secret values. Restore testing must use an isolated platform adapter so no replay contacts users.

What to understand

Encrypt and version backup and export with checksum, schema revision, application image, platform registration revision, policy, retention, and recovery objective. Backup identity is read-only.

Restore into an isolated database, disable all real platform and dispatch egress, verify event uniqueness, scoped state, action approvals, worker terminal states, rate buckets, and audit ordering, then destroy the temporary restore.

Release evidence includes Entra application type, credential mechanism and revision, Azure Bot endpoint, Teams manifest digest, tenant allowlist, admin-consent and RSC inventory, Agents SDK version, JWT rejection tests, tenant-isolation fixtures, duplicate activity behavior, moderation, Adaptive Card Action.Execute handling, approver and payload-hash tests, ambiguous dispatcher recovery, managed-identity canary, zero-downtime overlap, backup checksum, isolated restore, rollback, and revocation rehearsal.

System changes

  • Creates restrictive backup and audit export files plus an isolated restore during the exercise; production platform state is unchanged.

Syntax explained

umask 077
Restricts newly created backup and export files to the current account before managed encryption and transfer.
pg_dump custom format
Produces an inspectable PostgreSQL archive suitable for isolated, controlled restore without forcing production ownership.
privacy-minimized audit export
Exports lifecycle identifiers and timestamps rather than conversation or attachment content.
Command
Fill variables0/2 ready

Values stay on this page and are never sent or saved.

umask 077 && pg_dump --format=custom --no-owner --file={{backupPath}} "$DATABASE_URL" && psql "$DATABASE_URL" -Atc "SELECT row_to_json(x) FROM (SELECT sequence,event_type,object_id,created_at FROM bot_audit ORDER BY sequence) x" > {{exportPath}} && pg_restore --list {{backupPath}} | head -n 12
Example output / evidence
TEAMS_RECOVERY_OK tenants=3 events=18821 state_rows=421 drafts=38 approvals=12 historical_dispatches=0 external_calls=0

Checkpoint: Recovery evidence recorded

Continue whenBackup restores within objective, integrity checks pass, no fake adapter call targets production, minimized export matches audit counts, and temporary recovery state is deleted.

Stop whenStop if files are plaintext in shared storage, restore has platform egress, event or approval history is incomplete, or a real credential is required.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.
13

verification

Release by cohort and schedule the 90-day review

read-only

Release Microsoft Teams through synthetic, internal, and small real cohorts. Measure authenticity rejection, acknowledgement deadline, duplicate handling, state isolation, moderation, permissions, attachment policy, response quality, rate behavior, approval, dispatch ambiguity, content leaks, latency, usage, backup age, and restore evidence. Review official sources and policy every ninety days or after platform, SDK, identity, permission, event, model, attachment, or deployment changes.

Why this step matters

Release evidence includes Entra application type, credential mechanism and revision, Azure Bot endpoint, Teams manifest digest, tenant allowlist, admin-consent and RSC inventory, Agents SDK version, JWT rejection tests, tenant-isolation fixtures, duplicate activity behavior, moderation, Adaptive Card Action.Execute handling, approver and payload-hash tests, ambiguous dispatcher recovery, managed-identity canary, zero-downtime overlap, backup checksum, isolated restore, rollback, and revocation rehearsal. Acceptance must prioritize authenticated, scoped, moderated, idempotent, and accountable behavior over response eloquence. A kill switch should independently disable generated responses and consequential dispatch while deterministic help and safe status remain.

What to understand

Set thresholds before observing the cohort: zero unauthenticated processing, zero cross-scope state, zero unapproved writes, zero ambiguous replay, zero secret or content telemetry leaks, and platform deadlines plus rate guidance respected.

Evaluate by event type, scope, permission, message shape, language, attachment class, moderation class, duplicate, provider failure, platform failure, and old/new revision overlap. Do not use engagement as the only quality metric.

Record official source dates, SDK and API versions, registration and permission revisions, data retention, backup checksum, restore result, rollback rehearsal, open limitations, and next review date.

System changes

  • Changes traffic allocation and feature flags under release control; it does not itself send a message or perform a consequential action.
Example output / evidence
TEAMS_RELEASE_OK auth_bypass=0 cross_tenant=0 unapproved_writes=0 graph_scopes=0 rsc=0 critical_eval_failures=0 review_due=2026-10-27

Checkpoint: Production acceptance criteria met

Continue whenThe release report meets all security and reliability thresholds, records current Microsoft Teams and application revisions, and sets review due date 2026-10-27.

Stop whenStop or roll back on authentication bypass, missed acknowledgement, cross-scope state, unsafe content, unapproved write, ambiguous retry, telemetry leak, or failed restore.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.
14

warning

Teardown Microsoft Teams without losing approval or event evidence

danger

Disable new ingress and consequential dispatch, drain acknowledged work, reconcile every approved or ambiguous action, detach or disable the platform registration, create and restore-test the final encrypted backup, export required audit state, revoke platform, provider, approval, dispatcher, database, telemetry, migration, and backup credentials, then remove workloads and routes. Delete durable state only after retention, legal, security, and platform-owner approval.

Why this step matters

Deleting a registration, webhook, bot, application, or database too early can strand queued interactions, lose the explanation of an external write, or violate retention. A staged teardown separates stopping new work, reconciling effects, preserving required evidence, revoking access, and destructive deletion.

What to understand

Inventory Microsoft Teams registration, callback or messaging endpoints, command or manifest revisions, credentials, database, queues, backups, audit exports, attachments or caches, telemetry, provider records, images, DNS, certificates, and network policies.

Use two-person approval for database and backup deletion. Record identifiers, checksums, counts, approvers, timestamps, and exceptions without copying messages, attachments, cards, model output, or secret values.

If retention prevents deletion, freeze a read-only export service with ingress, model calls, and dispatch disabled; narrow identity and monitor every access until final deletion.

System changes

  • Disables platform traffic and dispatch, revokes credentials, removes workloads, and only after separate approval deletes durable bot state and backups.
Example output / evidence
TEAMS_TEARDOWN_READY bot_endpoint=disabled tenants=0 queue=0 ambiguous=0 final_backup=restored credentials_revoked=true app_uninstall=scheduled

Checkpoint: Teardown authorization confirmed

Continue whenNo new event or write occurs, every action has authoritative state, final backup restored, holds remain, credentials are revoked, platform registration is disabled, and deletion has named approvers.

Stop whenStop if work is in flight, an action is ambiguous, a hold is unresolved, backup has not restored, or a credential is shared with another workload.

Security notes

  • Treat every Microsoft Teams message, command option, event field, attachment name, card value, and quoted reply as untrusted data. Channel delivery authenticates the platform, not the truth or safety of user content.
  • Keep model and application credentials on the server. Hash stable actor identifiers for restricted audit, minimize conversation retention, and never copy tokens, raw attachments, message bodies, or generated responses into ordinary telemetry.
  • The assistant may create a local proposal, but any consequential external write requires an immutable draft, authenticated human approval of exact content, and a separately authorized dispatcher. Editing invalidates approval.

Alternatives

  • Retain a minimal read-only audit export service when policy prevents immediate deletion.

Stop conditions

  • Platform, security, privacy, legal, or service owner has not approved destructive deletion.
  • Final backup checksum, restore result, action reconciliation, or audit export is missing.

Finish line

Verification checklist

Authentication and idempotencynpm test -- --test-name-pattern='authentication|idempotency'Spoofed input fails before parsing or work, duplicate authenticated event processes once, and no cross-scope collision occurs.
Moderation, permissions, and attachmentsnpm test -- --test-name-pattern='moderation|permission|attachment'Unsafe or unauthorized input fails closed, attachment policy is enforced before download or provider use, and output rendering cannot create broad mentions or unsafe actions.
Approval and ambiguous dispatchnpm test -- --test-name-pattern='approval|dispatch|ambiguous'Only an exact approved draft reaches the fake dispatcher once; editing invalidates approval and ambiguous results block replay.
Recovery and zero-downtime overlapnode test/recovery.mjs --backup "$BACKUP_PATH" --restore "$RESTORE_DATABASE_URL" --platform-adapter fakeState restores, audit counts match, old and new workers process one event once, and no adapter call targets a real platform.

Recovery guidance

Common problems and safe checks

Every `/api/messages` request returns 401.

Likely causeAzure Bot application identity, tenant mode, messaging endpoint, JWT authority, credential, or Agents SDK auth configuration does not match.

Safe checks
  • Check Azure Bot and Entra client IDs without printing credentials
  • Confirm single-tenant versus multitenant registration
  • Inspect sanitized authorizeJWT failure class

ResolutionRestore the matching app registration and current Agents SDK auth configuration. Use a local secret only in development and the configured managed or federated identity in production.

Authenticated activity is denied as an unknown tenant.

Likely causeThe tenant ID is absent from the deployment allowlist, the database policy is inactive, or the app was installed in an unintended organization.

Safe checks
  • Compare normalized tenant ID to approved inventory
  • Inspect active tenant policy revision
  • Review Teams app assignment policy

ResolutionKeep the tenant denied until an owner approves onboarding, activates its policy, confirms isolation tests, and records the manifest and consent inventory.

The app installs but messages never reach the assistant.

Likely causeThe Azure Bot Teams channel, HTTPS messaging endpoint, app manifest bot ID, valid domains, or Teams app policy is wrong.

Safe checks
  • Check Azure Bot channel health
  • Compare manifest bot ID and endpoint app ID
  • Use a pilot chat and inspect sanitized activity receipt

ResolutionCorrect registration and manifest as one reviewed revision, re-upload or publish it under organization policy, then verify one pilot tenant before rollout.

A Graph-backed feature returns consent or authorization errors.

Likely causeThe application confuses bot authentication with Graph delegated or application permission, admin consent is missing, or user authorization was not performed.

Safe checks
  • Inventory exact Graph scope and access type
  • Inspect Entra enterprise application consent
  • Confirm authenticated user and target-resource authorization

ResolutionDisable the feature, choose the least-privileged documented Graph permission, implement user or application authorization correctly, obtain explicit admin consent, and test revocation.

A team-scoped operation is forbidden after app installation.

Likely causeThe Teams app manifest lacks required RSC declaration, consent was not granted on the target resource, or the operation incorrectly assumes tenant-wide permission.

Safe checks
  • Inspect manifest authorization.permissions.resourceSpecific
  • Check consent on the exact team or chat
  • Verify target resource ID under authenticated tenant

ResolutionKeep the operation disabled until the minimum RSC permission is declared, reviewed, granted on the exact resource, recorded, and revocation-tested.

One activity produces duplicate replies.

Likely causeThe activity ID is not persisted under tenant scope, old and new revisions use different databases, or processing occurs before the unique claim.

Safe checks
  • Query tenant and event ID together
  • Inspect primary key and transaction
  • Compare old and new deployment database endpoints

ResolutionShare one durable state, claim before response generation, reconcile existing response activity IDs, and do not replay an ambiguous activity.

Adaptive Card buttons display but do nothing.

Likely causeThe card is unsupported, Action.Execute payload is malformed, invoke name is not `adaptiveCard/action`, or invoke activity is not routed.

Safe checks
  • Validate Adaptive Card schema 1.5
  • Inspect sanitized invoke name and verb
  • Test in the target Teams client

ResolutionUse deterministic Action.Execute cards, handle only the documented invoke name and allowlisted verbs, and return a valid invoke response card.

Conversation data appears across tenants or chats.

Likely causeA query omits tenant ID or conversation ID, state key uses display names, or migration copied rows without scope.

Safe checks
  • Run cross-tenant and cross-conversation fixtures
  • Inspect composite primary keys
  • Trace every state query predicate

ResolutionDisable stateful responses, contain affected tenants, repair composite keys and predicates, expire suspect state, and complete privacy incident review.

A legitimate administrator cannot approve a draft.

Likely causeA Teams role or display name was mistaken for the application approver role, actor hashing changed, or the operator assignment is disabled.

Safe checks
  • Compare tenant-scoped actor hash
  • Inspect enabled teams_operators row
  • Review identity-secret revision

ResolutionAssign the authenticated actor explicitly under the tenant approver policy. Do not infer approval authority from Teams ownership, username, card fields, or model output.

Approval fails with a payload mismatch.

Likely causeThe exact payload changed after card creation, JSON normalization changed, or a client tried to submit replacement content.

Safe checks
  • Recompute hash from stored exact_payload
  • Compare immutable draft audit
  • Inspect card data for draft ID only

ResolutionCancel the draft, investigate mutation, and create a new draft and approval. Never approve a changed payload under an earlier review.

Dispatcher timed out after sending a request.

Likely causeThe downstream operation may have succeeded but no authoritative response was received.

Safe checks
  • Inspect draft state and idempotency key
  • Query downstream operation status through a reviewed read API
  • Preserve sanitized request and response identifiers

ResolutionKeep the draft ambiguous, do not retry blindly, reconcile the exact idempotency key, and require an operator decision if the downstream system has no authoritative status.

A user sends an attachment and expects analysis.

Likely causeThe baseline intentionally rejects attachments because permissions, download, scanning, retention, and provider consent are not implemented.

Safe checks
  • Confirm attachment count without fetching content
  • Review requested business purpose
  • Inventory minimum Graph or RSC permission

ResolutionAsk for bounded non-sensitive text or design a separately reviewed attachment profile with explicit consent, limits, malware scanning, retention, and authorization.

Model response contains a mention, link, or unsafe claim.

Likely causeOutput safeguards, moderation, or deterministic rendering were bypassed.

Safe checks
  • Run mention and link fixtures
  • Inspect output moderation class
  • Confirm cards are server-authored

ResolutionSuppress the response, use a neutral failure message, restore safeTeamsText and output moderation, and re-run the release evaluation before reopening.

Managed identity works locally with a secret but fails in production.

Likely causeThe workload identity, client ID annotation, Entra federated credential, Azure Bot identity, or tenant configuration does not match.

Safe checks
  • Inspect workload identity binding
  • Compare client and tenant IDs
  • Test token acquisition for api.botframework.com without logging token

ResolutionCorrect the managed or federated identity relationship, remove the production client secret, and repeat authenticated Bot Service canary and rollback tests.

Rolling deployment loses approvals or repeats work.

Likely causeRevisions do not share PostgreSQL state, schema compatibility is broken, or dispatcher replicas overlap without locking.

Safe checks
  • Inspect schema revision and database endpoint
  • Count dispatcher replicas and SKIP LOCKED claims
  • Run old/new duplicate fixtures

ResolutionStop rollout, keep the Bot endpoint stable, restore the previous schema-compatible image, reconcile drafts and activities, then repeat zero-downtime overlap testing.

After the procedure

Alternatives and next steps

Consider these alternatives

  • Run deterministic commands and static help only until identity, event deduplication, state scope, moderation, and recovery are proven.
  • Disable attachments and public responses for the first cohort; use private or one-to-one replies where the platform and policy support them.
  • Keep consequential actions entirely manual by presenting an approved copyable instruction instead of automated dispatch.
  • Use a managed queue or state store only when it preserves scoped uniqueness, immutable approval, backup, deletion, and reconciliation evidence.

Operate it safely

  • Review Microsoft Teams API, SDK, authentication, event, permission, response, rate, attachment, and deployment sources before 2026-10-27.
  • Replace compact static credentials with managed, federated, certificate, or short-lived subject identity supported by the platform and deployment.
  • Expand multilingual moderation and abuse fixtures, platform failure simulation, and labeled response evaluations without retaining real conversation data.
  • Move consequential dispatch to a transactional outbox with leases and explicit ambiguous-result operator reconciliation.
  • Run a quarterly backup restore, registration rotation, permission diff, secret rotation, zero-downtime overlap, kill-switch, and teardown rehearsal.

Reference

Frequently asked questions

Is the Microsoft Bot Framework SDK still the right starting point?

For this Guide, use the current Microsoft 365 Agents SDK and its AgentApplication, authorizeJWT, and CloudAdapter guidance. Preserve compatibility requirements deliberately rather than starting a new implementation on legacy samples.

Does a valid Teams activity authorize Microsoft Graph access?

No. Channel authentication proves the activity path. Graph delegated or application access requires the correct Entra permission, user or application authorization flow, target-resource authorization, and often administrator consent.

When is admin consent required?

It depends on the exact Entra delegated or application permission and tenant policy. Inventory each permission, follow Microsoft's current consent guidance, and never ask for broad consent just because the bot is installed.

What is Teams resource-specific consent?

RSC lets an app request declared access to a specific team, chat, or meeting resource. It is separate from tenant-wide Graph consent and must be authorized and checked for the exact resource.

Why use Action.Execute instead of Action.Submit?

Microsoft recommends Action.Execute for current server-processed and multi-step Adaptive Card workflows. The server must still authenticate the invoke, allowlist the verb, load authoritative state, and distrust client data.

Can the model create arbitrary Adaptive Cards?

No. The model produces bounded plain text only. Approval cards are deterministic server objects with fixed actions, and action data contains only a verb and immutable draft ID.

Can the assistant work across many organizations?

Only after an explicit multitenant design covering app registration, tenant onboarding, allowlisting, isolation, consent, offboarding, abuse, support, and incident handling. This reference remains deny-by-default per tenant.

When is this Guide reviewed again?

By 2026-10-27 and immediately after Agents SDK, Teams activity, Entra identity, Azure Bot, Adaptive Cards, Graph permission, RSC, OpenAI model, database, or deployment changes.

Recovery

Rollback

Rollback independently disables consequential dispatch and generated responses while preserving authenticated Microsoft Teams ingress, deterministic help where safe, durable event state, approval evidence, and export. Restore the previous immutable image, policy, command or manifest revision, and schema-compatible state without replaying an ambiguous response.

  1. Disable consequential dispatch and block the dispatcher credential before changing response generation or platform registration.
  2. Disable generated responses, retain only deterministic safe acknowledgement or help where the platform deadline and policy permit, and drain durable work.
  3. Shift to the previous immutable application, schema-compatible state, command or manifest, permission, and policy revision; verify authentication and idempotency.
  4. Reconcile every ambiguous platform response or approved draft by authoritative ID; never blindly resend during rollback.
  5. Run the synthetic security and recovery suite, export required audit evidence, document the trigger, and schedule a corrected canary.

Evidence

Sources and review

Verified 2026-07-29Review due 2026-10-27
Microsoft 365 Agents SDK AgentApplicationofficialMicrosoft 365 Agents SDK JavaScript quickstartofficialConfigure authentication in a JavaScript agentofficialMicrosoft 365 Agents SDK authentication configurationofficialMicrosoft Teams Adaptive Card Action.Execute guidanceofficialMicrosoft Teams app permissions and admin consentofficialMicrosoft Teams resource-specific consent permissionsofficialMicrosoft Entra tenant-wide admin consentofficialAdaptive Cards Action.Execute schemaofficialOpenAI moderation for Teams message input and outputofficialPostgreSQL explicit locking for Teams approvals and dispatchofficial