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 theclarify:temporaljob kind.grpc.token_keyset (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 atcheck-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 carriesorganizationIdbecause callbacks do not. rolesis the deny-by-default RBAC gate: a person absent from the map has no read permission and cannot receive question cards. Rights areviewer | editor | steward.stewardAliasesexists because the steward is usually not a ranked candidate: escalation alerts need delivery aliases the clarification's ownaskedAliasesdoes not cover.- Defaults if omitted:
channels"teams,slack" (first adapter with credentials AND a resolvable alias wins),cardSpanMaxChars280,perPersonDailyCap5,digestThreshold3,telhaUrlfromTELHA_URLelsehttp://127.0.0.1:7625,tokenKeyEnvTELHA_TOKEN_KEY,grpcAddressfromTELHA_GRPC_ADDRESSelse127.0.0.1:7626,workerId"clarify-bot". ThetelhaUrlandgrpcAddressfallbacks 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", identityworkerId) to leaseclarify: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-partyresponder(anything else gets403 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)¶
- Register a Bot Framework / Azure Bot resource; record the app id and generate an app password (client secret). These become
teams.appId/appPasswordEnv. - Set the messaging endpoint to your public
/hooks/teamsURL. - 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. - Identity binding is automatic: Teams callbacks carry
from.aadObjectId, which is the Entra connector'saad_oidjoin key.
Slack install (per tenant)¶
- Create a Slack app in the workspace; enable Interactivity with the
/hooks/slackrequest URL; install and record the bot token and signing secret (botTokenEnv/signingSecretEnv), plus the workspace team id (teamId). - Grant scopes for DM delivery (
chat.postMessageto a DM) and profile reads (users.info- the verified-match path reads profile emails). - 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:temporaljob 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 isclarify.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) andtelha 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 envTELHA_MCP_URL) - core's MCP endpoint (default listener127.0.0.1:7627; requiresmcp.enabledandgrpc.token_keyon core).tokenKeyEnv(defaultTELHA_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 toANTHROPIC_API_KEY.modeloptionally 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:
generateandgetTraceare admin-only chat tools, and the bot maps onlysteward -> 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.openuses 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:
providerisentraoroidc; one IdP per tenant.- Deny-by-default:
groupRoleMapmust 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 areviewer | editor | admin | steward. - Ceilings enforced:
sessionAbsoluteHoursmax 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_oidwhen the Entra connector has synced one; PERSON-attributed actions on an unlinked session returnIDENTITY_NOT_LINKED. Sessions never auto-create PERSON nodes. - Known deviation: Entra groups-claim overage refuses login with a typed
GROUPS_OVERAGEerror (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¶
- Connectors - the Entra identity substrate
- Operator handbook - steward CLI, key inventory
- Quickstart