Skip to content

Clarify Bot - Teams & Slack Runbook

The clarify-bot (app-layer/packages/clarify-bot/) is Telha's single chat presence: it delivers clarification question cards to Teams and Slack DMs, receives the button callbacks, submits verified answers to core, and (optionally, PR-070) answers "Ask Telha" questions in the same DM. Protocol guarantees (DM-only delivery, verified callbacks, no free-text binding, per-tenant credentials) come from the chat-delivery-surface spec, approved v0.4 with as-builts v0.5/v0.6.

Honesty note: the bot ships with a complete offline test suite (FakeAdapter, scripted callbacks, 59 tests green) but the live Teams and Slack legs have not been exercised against real tenants yet. Expect to debug the first real install.

Prerequisites (core side)

In core config:

  • [clarify] enabled = true (default false) - gates CLARIFICATION creation and the clarify:temporal job kind.
  • grpc.token_key set (64 hex, at least one letter) - the bot signs its own service tokens from this shared key. Without it, clarifications are created and routed but nothing can poll or answer them (core logs exactly this warning at check-config).
  • The Entra connector synced - identity is the routing and binding substrate.

Configuration: CLARIFY_BOT_CONFIG

The bot loads CLARIFY_BOT_CONFIG - either a path to a JSON file or inline JSON (starts with {). Secrets are referenced by env-var NAME in the file (appPasswordEnv, botTokenEnv, signingSecretEnv, tokenKeyEnv) and resolved at load; no secret material lives in the file. Exact shape (from clarify-bot/src/config.ts):

{
  "tenants": {
    "11111111-1111-4111-8111-111111111111": {
      "organizationId": "22222222-2222-4222-8222-222222222222",
      "teams": {
        "appId": "<bot framework app id>",
        "appPasswordEnv": "TEAMS_APP_PASSWORD_T1",
        "serviceUrl": "https://smba.trafficmanager.net/amer/"
      },
      "slack": {
        "botTokenEnv": "SLACK_BOT_TOKEN_T1",
        "signingSecretEnv": "SLACK_SIGNING_SECRET_T1",
        "teamId": "T0123456789"
      }
    }
  },
  "channels": "teams,slack",
  "cardSpanMaxChars": 280,
  "perPersonDailyCap": 5,
  "digestThreshold": 3,
  "stewardPersonId": "<PERSON uuid>",
  "stewardAliases": { "aadObjectId": "<aad oid>", "slackUserId": "U..." },
  "roles": {
    "<person uuid>": "editor",
    "<person uuid>": "steward"
  },
  "telhaUrl": "http://127.0.0.1:7625",
  "tokenKeyEnv": "TELHA_TOKEN_KEY",
  "grpcAddress": "127.0.0.1:7626",
  "workerId": "clarify-bot",
  "hooksPort": 3979,
  "ask": {
    "enabled": false,
    "mcpUrl": "http://127.0.0.1:7627",
    "tokenKeyEnv": "TELHA_GRPC_TOKEN_KEY",
    "anthropicApiKeyEnv": "ANTHROPIC_API_KEY",
    "model": "claude-sonnet-4-5"
  }
}

Key facts, verified against the loader:

  • Credentials are per Telha tenant and cross-tenant delivery is structurally impossible: callbacks are tenant-routed by credential match (Teams app id, Slack teamId), never by caller-supplied ids. Each tenant entry carries organizationId because callbacks do not.
  • roles is the deny-by-default RBAC gate: a person absent from the map has no read permission and cannot receive question cards. Rights are viewer | editor | steward.
  • stewardAliases exists because the steward is usually not a ranked candidate: escalation alerts need delivery aliases the clarification's own askedAliases does not cover.
  • Defaults if omitted: channels "teams,slack" (first adapter with credentials AND a resolvable alias wins), cardSpanMaxChars 280, perPersonDailyCap 5, digestThreshold 3, telhaUrl from TELHA_URL else http://127.0.0.1:7625, tokenKeyEnv TELHA_TOKEN_KEY, grpcAddress from TELHA_GRPC_ADDRESS else 127.0.0.1:7626, workerId "clarify-bot". The telhaUrl and grpcAddress fallbacks match core's default ports.

Tokens

The bot holds the shared grpc.token_key (via tokenKeyEnv) and mints its own short-lived (300 s) tokens:

  • a worker-shaped clarify token (audience "clarify", identity workerId) to lease clarify: jobs over gRPC, and
  • a tenant-shaped clarify token per callback to call POST /v1/clarifications/:id/answer - the only credential class allowed to assert a third-party responder (anything else gets 403 RESPONDER_ASSERTION_FORBIDDEN).

For manual probes an operator can mint the same tokens:

telha api-key clarify-token --worker-id clarify-bot          # polling identity
telha api-key clarify-token --tenant <uuid> --org <uuid>     # answer-surface token

Hooks endpoints and public URLs

The bot exposes two inbound endpoints (raw node:http, hooksPort):

  • POST /hooks/teams - Bot Framework activities; JWT verified (issuer, audience, signature) before processing.
  • POST /hooks/slack - interactivity payloads; signing-secret HMAC over the raw body with a plus/minus 300 s timestamp window, verified FIRST, then acked within Slack's 3 s window, then processed async.

Both must be reachable from the public internet (Microsoft and Slack call them), so put them behind TLS termination and point:

  • the Teams bot registration's messaging endpoint at https://<public-host>/hooks/teams, and
  • the Slack app's Interactivity request URL at https://<public-host>/hooks/slack.

Per-IP rate limiting precedes all processing; verification failures and rate-limit events go to the audit sink. Unverified callbacks are dropped, never processed.

Teams app deployment (per tenant)

  1. Register a Bot Framework / Azure Bot resource; record the app id and generate an app password (client secret). These become teams.appId / appPasswordEnv.
  2. Set the messaging endpoint to your public /hooks/teams URL.
  3. Package the bot in a Teams app manifest and deploy it org-wide (Teams admin center > org-wide app deployment). Proactive 1:1 delivery requires the app installed for the user; where it is not, delivery degrades to a skip (NO_DELIVERY_ALIAS, audit hint "app not installed") and the ranking advances.
  4. Identity binding is automatic: Teams callbacks carry from.aadObjectId, which is the Entra connector's aad_oid join key.

Slack install (per tenant)

  1. Create a Slack app in the workspace; enable Interactivity with the /hooks/slack request URL; install and record the bot token and signing secret (botTokenEnv / signingSecretEnv), plus the workspace team id (teamId).
  2. Grant scopes for DM delivery (chat.postMessage to a DM) and profile reads (users.info - the verified-match path reads profile emails).
  3. Identity binding is lazy and verified: a Slack identity binds only when its profile email matches exactly one Entra-verified person; the bot then records the alias through core's audited POST /v1/person-aliases/verified-match (clarify-audience only). Unmapped identities can never bind answers; they get a "could not be verified" card update and an audit row.

The delivery protocol, in operator terms

  • The clarify:temporal job IS the delivery. The bot leases it, sends exactly one new card per lease (attempt N asks ranked candidate N), then deliberately stops heartbeating. The lease expires and the reaper re-pends the job with attempts+1 behind a per-kind backoff whose base is clarify.escalation_window_hours (core config, default 48 h). Escalation windows are job backoff - "unanswered past the window" and "advance to the next candidate" are the same mechanism.
  • The final attempt (past the ranking) is steward escalation.
  • Sends are idempotent across crashes and re-leases: a nonce and delivery record are persisted BEFORE the send; a re-leased job re-delivers the same card with the same nonce, never a duplicate.
  • Answers are accepted only as discriminated card actions ({kind: "clarify_answer", clarification_id, nonce, ...}). Free-text replies never bind, ever; there is no NLP fallback.
  • Watch it from the operator side with telha clarify show <id> --tenant ... --org ... (delivery job history, attempt-to-candidate mapping) and telha clarify queue (what needs steward attention). See the operator handbook.

Ask-in-chat (PR-070, default off)

With ask.enabled = true, conversational (non-button) messages route to the ai-assistant chat engine over MCP instead of a redirect reply:

  • mcpUrl (or env TELHA_MCP_URL) - core's MCP endpoint (default listener 127.0.0.1:7627; requires mcp.enabled and grpc.token_key on core).
  • tokenKeyEnv (default TELHA_GRPC_TOKEN_KEY) - signs mcp-audience tokens; falls back to the clarify token key (same shared key).
  • anthropicApiKeyEnv - names the env var holding the Anthropic key; absent lets the SDK default to ANTHROPIC_API_KEY. model optionally overrides the provider default.

Asking requires a bound PERSON and uses the same roles map (viewer by default; steward maps to admin). Replies land in the same DM. The clarification write path is untouched: the ask handler has no route to the answer endpoint by construction. ask.enabled without a reachable mcpUrl fails at boot, not at first question.

Grounded answers as evidence cards

When the ask run grounds the answer with the generate tool, the reply is delivered as a Block Kit / Adaptive Card, not plain text: the answer, the top cited evidence (each with a supported / partial / unsupported verdict), the verification counts, and an "Open full trace" button that deep-links to ${appBaseUrl}/trace/:id in the web app. Set appBaseUrl in CLARIFY_BOT_CONFIG (or TELHA_APP_URL) to the public web host; absent, the card omits the link.

  • Reach: generate and getTrace are admin-only chat tools, and the bot maps only steward -> admin, so today only stewards receive the evidence card. Viewers and editors get a text answer. Widening this to editors/viewers is a deliberate deny-by-default allowlist change, pending product and security sign-off.
  • A trace-fetch failure or a non-grounding run degrades cleanly to the text reply. The DM-only guarantee and the "free text never binds" invariant are unchanged.
  • The Slack "Something else..." custom answer opens a modal (a date picker); no extra Slack scope is needed beyond the Interactivity you already enabled, since views.open uses the same bot token.

Web login (SSO) for the app layer

The web side of the app layer (chat, contracts, traces) authenticates humans via OIDC + PKCE (sso-auth spec, as-built v0.3, PR-082). Config loads from TELHA_AUTH_CONFIG (path or inline JSON, same convention as the bot). Exact keys (from packages/core/src/auth/config.ts):

{
  "tenants": {
    "11111111-1111-4111-8111-111111111111": {
      "organizationId": "22222222-2222-4222-8222-222222222222",
      "provider": "entra",
      "issuer": "https://login.microsoftonline.com/<entra-tenant-id>/v2.0",
      "clientId": "<app registration client id>",
      "clientSecretEnv": "SSO_CLIENT_SECRET_T1",
      "redirectUri": "https://<app-host>/auth/callback",
      "groupsClaim": "groups",
      "groupRoleMap": { "<group object id>": "editor", "<group object id>": "steward" },
      "sessionAbsoluteHours": 8,
      "sessionIdleMinutes": 60,
      "roleRefreshMinutes": 15,
      "hosts": ["telha.example.com"]
    }
  },
  "defaultTenant": "11111111-1111-4111-8111-111111111111"
}

Facts, verified against the loader and as-built record:

  • provider is entra or oidc; one IdP per tenant.
  • Deny-by-default: groupRoleMap must map at least one group (config refuses to load otherwise); a user with no mapped group gets a 403 at callback and no session. Roles are viewer | editor | admin | steward.
  • Ceilings enforced: sessionAbsoluteHours max 24; idle and refresh intervals are clamped to the absolute lifetime.
  • Endpoints: GET /auth/login, GET /auth/callback, POST /auth/logout (origin-checked). Sessions are server-side records behind an opaque HttpOnly cookie; logout revokes immediately.
  • Login binds the session to a PERSON via aad_oid when the Entra connector has synced one; PERSON-attributed actions on an unlinked session return IDENTITY_NOT_LINKED. Sessions never auto-create PERSON nodes.
  • Known deviation: Entra groups-claim overage refuses login with a typed GROUPS_OVERAGE error (the Graph-lookup fallback is a follow-up). Live Entra click-through still needs a real app registration (redirect URI = redirectUri, groups claim enabled on the app's token configuration).

See also