Skip to main content

Feature: Channels

Channels are the communication surfaces through which a client team contacts their assigned Specialist persona. Each inbound message is normalized and routed into the conversation system regardless of source channel. Channel routing is one direction: inbound from the client, outbound from the Specialist (operated by Humanity Protocol's Expert + AI agent team).

⚠️ Historical audit context β€” see archived audit archive/MVP_CLIENT_CHANNELS_AUDIT_2026-05-22.md for the full readiness PM-cut snapshot from 2026-05-22. The headline finding (outbound dispatch broken on non-email channels) has since been resolved by PR #567 (ChannelDispatcherService); #576 / PR #621 closed the auto-respond mirror. Inbound and outbound media now flow end-to-end on Email/WhatsApp/Slack/Telegram via DispatchExpertReplyParams.attachments (#669 closes #559). Telegram signature verification fix landed in fix(security): correct JWT expiry docs and fix Telegram webhook auth (commit 080f458). Read the status table below with these caveats.

Channel Overview​

ChannelStatusAuth MethodNotes
Emailβœ… Live (Gmail API + DWD; CF Worker fallback during 2-week soak)Gmail API polling via cron + Resend (transactional only)2026-06-05 cutover (#1722, closes #1060): inbound now comes via Gmail API polling (*/30 * * * * *) against per-OSA Workspace mailboxes; outbound replies are sent from the Specialist mailbox via DWD service-account impersonation (single GCP service account, no per-OSA OAuth tokens). Cloudflare Worker remains as fallback during a 2-week prod soak β€” tracking issues #1440, #1441 stay open until retirement. See three-system overview below + Long-Term Plan section.
WhatsAppβœ… Live (Twilio)HMAC-SHA1 (Twilio sig)One inbound number per environment (WHATSAPP_PHONE_NUMBER); all orgs on the env share it. Sender identity (which org a phone belongs to) is established by self-service pairing codes (whatsapp_linked_numbers). Open: identity attribution bug #677 (P0).
Slackβœ… Live (Connect flow + manifest paste)HMAC-SHA256 (per-org signing_secret from integration_credentials)Connect flow flipped Coming-Soon β†’ Available in #816 (closes #706). Manifest suggester at /client/admin/channels/slack via GET /orgs/:orgId/channels/slack/manifest (#1034). Inbound now lives in SlackModule (api/src/channels/slack/) per #1212/#1158. Per-org signing_secret + bot_token resolved via integration_credentials; env-var fallback is dev-only / disallowed in production (#1219, BLK-004).
Telegramβœ… LiveHMAC-SHA256 secret token
Web (widget)βœ… Live (embeddable)Visitor JWT via POST /public/widget/session (24h TTL)Embeddable customer-facing chat widget (channel: "web"). Standalone JS bundle a client embeds on their own site; visitors chat with their Specialist over REST + Socket.IO. Originally PR #926 (closes #43). See Web (embeddable widget) below, the feature spec, and the embed integration guide.
Teams⚠️ StubBot FrameworkNeeds credentials (#122)
WeChat⚠️ StubApp secretXML parsing not yet implemented

Config scope on /client/settings/channels (org-level vs per-user)​

The client Channels settings page is an org-level surface: every role that can access it (org_role owner/admin, plus superadmin) sees the same configuration, because the data is scoped by org_id β€” one config per org, not per user.

SectionBackend scopeSame for all roles?
Integrations (Slack / Email / Teams credentials)WHERE org_id (integrations/credentials/credentials.service.ts findAll)βœ… org-level
Email whitelistWHERE org_id (organizations.service.ts getEmailWhitelist)βœ… org-level
Slack install statusWHERE org_id + type (channels/slack OAuth status)βœ… org-level
WhatsApp linked numbersWHERE user_id (whatsapp-pairing.service.ts listForUser)❌ per-user (by design)

WhatsApp linked numbers are per-user, not org-level β€” intentionally. A WhatsApp link binds an individual's phone to the org's WhatsApp channel (whatsapp_linked_numbers carries both user_id and org_id, but GET /whatsapp/linked-numbers filters by the caller's user_id). So two admins of the same org can legitimately see different WhatsApp link lists (each sees their own). This is expected β€” personal data surfaced on the org settings page, not an org-level config inconsistency. Everything else on the page is org-scoped and identical across roles.

Email​

Overview​

h.work uses three distinct email systems. Do not confuse them.

SystemPurposeProviderWho configures
TransactionalInvites, notifications, OTPsResend (RESEND_API_KEY)HP internal
Specialist inbound (primary, 2026-06-05+)Clients email bob@{HWORK_DOMAIN}``Gmail API polling + DWD (System 4 below)HP infra
Specialist inbound (fallback during 2-week soak)sameCloudflare Email Routing β†’ webhook (System 2 below; tracking #1441)HP infra
Expert personalHP staff mailboxes (david@ex.{HWORK_DOMAIN}``)Google WorkspaceHP IT

2026-06-05 status: System 4 (Gmail API polling + DWD impersonation) shipped via PR #1722 and is now the primary inbound + outbound path for Specialist email. System 2 (Cloudflare Email Routing) remains as a fallback during a 2-week prod soak; #1441 tracks the final decommission. The Sytem 2 description below is preserved for the soak window β€” read System 4 first.


System 1 β€” Transactional Email (Resend)​

Used for platform-generated emails: onboarding invites, OTP codes, welcome emails, AM completion notifications.

  • Provider: Resend (RESEND_API_KEY env var, already set in Railway)
  • From address: h.work <onboarding@h852.work> (staging) β†’ onboarding@h.work once domain is verified in Resend
  • Code: api/src/email/email.service.ts (EmailService)
  • Pending: h.work domain needs to be verified in Resend dashboard for production use

System 2 β€” Specialist Inbound Email (Cloudflare β†’ Webhook β†’ Resend)​

⚠️ Current implementation (MVP). This system treats the Specialist email as a lightweight routing alias. The long-term plan is to replace this with full Google Workspace identities (see Long-Term Plan: Google Workspace Identities below).

Used when clients email their assigned Specialist (bob@h.work) to start or continue a conversation.

Architecture:

Client email β†’ bob@h.work (Cloudflare MX record on h.work)
β”‚
β–Ό
Cloudflare Email Routing
(forward rule: *@h.work β†’ /channels/email/inbound)
β”‚
β–Ό
POST /channels/email/inbound (our webhook)
β”‚
β–Ό
ConversationsService.findOrCreateByChannel()
β”‚
β–Ό
Hermes resumes the Specialist's SessionDB session
β”‚
β–Ό
Canonical reply β†’ Resend API sends from bob@h.work (DKIM)

Key details:

  • Specialist email is a routing alias β€” not a real mailbox; Cloudflare routes inbound, Resend sends outbound
  • Sender authorization is two-tier: org membership is the primary check (the FROM address must belong to a user with a row in org_memberships for one of the Specialist's orgs); the legacy email_whitelists table is consulted only as a fallback for senders without a portal account (e.g. personal addresses)
  • Unknown senders receive a one-shot auto-reply ("This specialist inbox is for registered users only…") sent from the Specialist's own routing address so threading visually matches
  • Reply threading via In-Reply-To header β€” subsequent emails attach to existing conversation
  • Customer ID is deterministic: email:<from_address>
  • Outbound replies sent via Resend API, DKIM-signed as the Specialist routing address
  • Returns 200 immediately; processing is async (setImmediate)
  • NOT SendGrid β€” old code used SendGrid Inbound Parse; this has been replaced with Cloudflare Email Routing + Resend
  • All inbound/outbound email events logged to email_audit_log table
  • Per-subject threading via In-Reply-To; channel_message_id used for dedup

Setup requirements (HP infra):

RequirementDetails
Cloudflare Email RoutingEnable on {HWORK_DOMAIN} in Cloudflare dashboard
Cloudflare WorkerDeploy relay worker (see docs/ops/EMAIL-CHANNEL-SETUP.md)
Forward rule*@{HWORK_DOMAIN}`` β†’ Cloudflare Worker β†’ POST /channels/email/inbound
MX recordCloudflare sets automatically when Email Routing is enabled
Domain in ResendVerify {HWORK_DOMAIN} in Resend dashboard for DKIM on outbound replies

Per-org credentials (stored in integration_credentials table, optional override):

KeyDescription
resend_api_keyResend API key for outbound (falls back to RESEND_API_KEY env var)
from_emailSpecialist's routing address (e.g. bob@h.work)

System 3 β€” Expert Personal Email (Google Workspace)​

HP staff (david@ex.h.work) have personal mailboxes via Google Workspace on the ex.h.work subdomain.

  • These are NOT routed through the platform
  • Expert responses to clients always go out as the Specialist address (bob@h.work), never from david@ex.h.work
  • HP IT manages Google Workspace externally; no platform code involved

System 4 β€” Gmail API inbound ingestion (per-OSA Workspace mailboxes) β€” #1722​

Current implementation warning: PR #1722 shipped a 30-second Gmail API poller. That is implementation history, not the desired event-driven shape. It must move to Gmail push/history notifications (or another mutation event) before this channel is considered compliant with the no-idle-polling contract. The poller must not be copied into conversation, queue, transcript, AgentFS, or SessionDB consumers.

For OSAs that have a provisioned Google Workspace mailbox, inbound email is currently fetched via the legacy Gmail API polling cron (*/30 * * * * * on Railway dev). This describes the code that still needs replacement; it is not an endorsed architecture. The target is an external Gmail mutation notification followed by one bounded history read for that mailbox. Idle mailboxes must produce no reads.

Architecture:

Client email β†’ {firstname}.{org-slug}@h.work (real Gmail mailbox)
β”‚
β–Ό
Gmail mailbox accumulates messages
β”‚
β–Ό
Railway cron (*/30 * * * * *) β†’ GmailIngestCron
β”‚ iterates over provisioned OSAs
β”‚ GCP service account (DWD) impersonates each OSA user
β”‚ Gmail API users.messages.list (q: newer than last historyId)
β”‚ users.messages.get for each new message
β–Ό
ConversationsService.findOrCreateByChannel()

Relationship to System 2 (Cloudflare): Gmail API polling is the primary inbound path for provisioned OSAs; the Cloudflare relay remains as a fallback during the 2-week soak and for un-provisioned OSAs. Routing is decided by org_specialist_assignments.gsuite_status β€” provisioned OSAs use polling, others fall through to Cloudflare.

Follow-ups filed 2026-06-06: #2064 (RFC 2822 Message-ID handling), #2065 (historyId observability).


Long-Term Plan: Google Workspace Identities​

Status (2026-06-05): βœ… SHIPPED β€” DWD single-SA impersonation live (PR #1722, commit d8333558, closes #1060). Inbound = Gmail API polling cron; outbound = Specialist mailbox send-as via DWD impersonation. Per-OSA OAuth tokens (ADR-0002 Phase 2) are deprecated β€” OAuthTokenService is no longer in the dispatch hot path; a single GCP service account with domain-wide delegation now impersonates each OSA mailbox. Cloudflare relay (System 2) remains as fallback during the 2-week prod soak.

Decision: Each Org Γ— Specialist Assignment (OSA) is provisioned a real Google Workspace user. The alias format is {firstname}.{org-slug}@{HWORK_DOMAIN} (note: not bob@h.work β€” this gives every (Org, Specialist) pair its own Workspace identity). Specialists become fully autonomous identities that the runtime can operate programmatically via a single platform-owned service account with DWD.

What shipped (2026-06-05, PR #1722 supersedes ADR-0002 Phase 2):

ComponentPRDescription
Gmail API polling cron#1722*/30 * * * * * cron fetches new messages per provisioned OSA via DWD impersonation (replaces Pub/Sub design from #1432)
Outbound via DWD impersonation#1722ChannelDispatcherService sends Specialist replies through Gmail API as the OSA user β€” no per-OSA OAuth tokens involved
WorkspaceProvisioningProcessor#1076BullMQ worker that drives Admin Directory provisioning + lifecycle (still current)
OSA Gsuite lifecycle schema#1065org_specialist_assignments.email_alias, workspace_user_id, gsuite_status
WorkspaceLifecycleModule#1074NestJS module wiring the above
assignSpecialist provisioning hook#1089OrganizationsService.assignSpecialist enqueues a provisioning job
Specialist Email field dropped from Create form#1097The catalog Specialist no longer carries an Email field β€” the alias is per-assignment, not per-catalog-Specialist
OAuthTokenService (deprecated)#1079Per-OSA OAuth token store β€” superseded by DWD impersonation in #1722; no longer in the dispatch path
Outbound Gmail Send via per-OSA OAuth (replaced)#1095Replaced by DWD impersonation in #1722

Architecture (live):

GCP Service Account (single, platform-owned)
+ domain-wide delegation on h.work Workspace
β”‚
β”œβ”€ impersonate {firstname}.{org-slug}@h.work β†’ Gmail API (polling + send)
β”œβ”€ impersonate {firstname}.{org-slug}@h.work β†’ Calendar API (create events, freebusy, Meet links)
β”œβ”€ impersonate {firstname}.{org-slug}@h.work β†’ Drive API (share files, manage folders)
β”‚
└─ Admin Directory API (no impersonation needed)
β”œβ”€ POST /admin/directory/v1/users ← WorkspaceProvisioningProcessor on assignSpecialist
└─ DELETE /admin/directory/v1/users/{key} ← on OSA removal

Key properties (as shipped 2026-06-05):

  • Single service account JSON key in Railway env β€” no per-OSA OAuth tokens stored or refreshed at runtime
  • Domain-wide delegation grants set once in the Google Admin Console by HP IT
  • All Gmail/Calendar/Drive API calls impersonate the target per-OSA Workspace user via DWD
  • Provisioning triggered from OrganizationsService.assignSpecialist β†’ enqueues a WORKSPACE_JOB_CREATE job (#1076/#1089)
  • Inbound: Gmail API polling cron (*/30 * * * * *); Cloudflare relay is the fallback during 2-week soak
  • Outbound: Gmail API send-as the per-OSA user via DWD; Resend is not used for Specialist conversation outbound (Resend remains only for invites/onboarding/transactional via email/email.service.ts)
  • Cost: ~$6/user/month (Google Workspace Business Starter). Licences scale with active OSA count

What stays the same:

  • Outbound always from a Specialist-class address β€” a real per-OSA Gmail mailbox via DWD impersonation (Resend is not used for Specialist outbound)
  • Persona integrity β€” clients still only see the Specialist alias, never the Expert's personal address
  • email_audit_log table continues to log all events

Sender Authorization​

The inbound handler checks senders in this order:

  1. Org membership (primary): join org_memberships to users on LOWER(users.email) = <FROM> and org_id IN (<specialist's orgs>). Most users β€” anyone with a portal login β€” are authorized this way without any extra setup.
  2. email_whitelists exact email match (fallback): for users without a portal account who still need to email in.
  3. email_whitelists domain match (fallback): wildcard authorization for an entire corporate domain.
  4. No match β†’ unknown sender: auto-reply "This specialist inbox is for registered users only…" from the Specialist's address, then drop. Logged to email_audit_log with action=auto-replied:unknown_sender.

Whitelist Management API​

  • GET /onboarding/:token/whitelist β€” list entries
  • POST /onboarding/:token/whitelist β€” add entry (during onboarding)
  • DELETE /onboarding/:token/whitelist/:id β€” remove entry
  • Default: empty. Most orgs no longer need any whitelist entries β€” invited team members are authorized via org_memberships. Add entries only for senders who don't have portal logins.

WhatsApp​

WhatsApp is delivered via one Twilio number per environment (staging has its own number, production has its own). All clients on the env share that single inbound number; the Specialist/persona identity (Bob, Nova, etc.) is carried entirely at the conversation/Specialist layer, not by the dialled phone number. Sender→org identity is established at runtime via self-service pairing codes generated from the client portal.

Single-Number Model​

Each environment has exactly one inbound WhatsApp number, configured via the WHATSAPP_PHONE_NUMBER env var. The platform looks up which org a message belongs to by the sender's phone (From) via whatsapp_linked_numbers, never by the receiving number (To). One number can therefore serve every org on the env.

Outbound replies always go from the same number that received the message (i.e. WHATSAPP_PHONE_NUMBER).

Legacy: the whatsapp_number_pool table and the per-Specialist pool-assignment flow were the v1 design. They are retained for back-compat but no longer required for routing β€” getOrgWhatsAppNumber() reads WHATSAPP_PHONE_NUMBER directly.

Pairing Code Flow (canonical sender onboarding)​

  1. User signs into the portal β†’ Settings β†’ WhatsApp (/client/settings/whatsapp/).
  2. Dashboard generates a one-time HW-XXXX code (15-minute TTL, copy + refresh buttons, live countdown). Stored in whatsapp_pairing_codes with userId + orgId.
  3. User texts the code to the env's WhatsApp number on WhatsApp.
  4. Inbound webhook detects the HW-XXXX pattern, consumes the code, and creates a row in whatsapp_linked_numbers (userId, orgId, phoneE164). A confirmation reply "Connected to {OrgName}" is sent.
  5. Future messages from that phone route automatically to the user's org conversation.
  6. The same Settings page lists linked numbers and lets the user unlink them.

Constraints:

  • One phone ↔ one org max. Re-pairing the same phone to a different org is rejected with a "you're already connected, unlink at /client/settings/profile" reply, and the new code is not burned.
  • A user may link multiple phones to the same org (e.g. personal + work).
  • Invalid / expired / already-used codes get a "not recognised, generate a new one from the dashboard" reply; the code in the message is ignored, not consumed.

Inbound Routing Priority​

The inbound handler (routeTwilioWhatsApp in api/src/channels/channels.controller.ts) tries these in order:

  1. Pairing-code message β€” if the body matches ^HW-[A-Z0-9]{4}$ (case-insensitive) β†’ consume code + reply confirmation (or rejection per the rules above). Checked first so a user can re-pair even if already linked.
  2. whatsapp_linked_numbers lookup on From β†’ route to the linked org's conversation.
  3. whatsapp_sender_whitelist lookup (To + From) β†’ legacy fallback for orgs that pre-date the pairing flow.
  4. Unknown sender β†’ auto-reply pointing to the dashboard at https://app.{HWORK_DOMAIN}/client/settings/profile (rate-limited once per 24h per sender via Redis).

Architecture​

Inbound WhatsApp message
β”‚
β–Ό
WhatsApp Servers (Meta)
β”‚
β–Ό
Twilio WhatsApp Gateway
β”‚ POST /channels/whatsapp/inbound
β”‚ X-Twilio-Signature (HMAC-SHA1)
β–Ό
humanwork-api (NestJS)
β”‚ 1. Verify Twilio HMAC-SHA1 signature
β”‚ 2. Deduplicate via Redis (MessageSid, 1-hour TTL)
β”‚ 3. Match against routing priority (pairing code β†’ linked β†’ whitelist β†’ unknown)
β”‚ 4. For pairing codes: write whatsapp_linked_numbers, mark code used
β”‚ 5. For known senders: dispatch to ConversationsService / ExpertQueue
β–Ό
ConversationsService β†’ ExpertQueue

Tables​

TablePurpose
whatsapp_linked_numbersPairing result: (userId, orgId, phoneE164) β€” the canonical routing key
whatsapp_pairing_codesLive HW-XXXX codes with TTL, userId, orgId
whatsapp_number_poolLegacy. No longer required for routing; WHATSAPP_PHONE_NUMBER env var supersedes it. Admin endpoints remain for back-compat
whatsapp_sender_whitelistDeprecated. Legacy routing fallback only. Do not configure for new orgs

Other Key Details​

  • Outbound replies are sent via Twilio REST API from the same number that received the message (always WHATSAPP_PHONE_NUMBER for the env).
  • Webhook: POST /channels/whatsapp/inbound β€” Twilio delivers application/x-www-form-urlencoded payload.
  • Signature verification: HMAC-SHA1. TWILIO_AUTH_TOKEN (main account) is preferred, with TWILIO_SUBACCOUNT_TOKEN as fallback for subaccount-owned senders. The env's WhatsApp number lives on the main account, so verification typically succeeds against TWILIO_AUTH_TOKEN.

Configuration (Railway env vars)​

VariableDescription
WHATSAPP_PHONE_NUMBERE.164 inbound number for this env (one per env). All orgs share it
TWILIO_ACCOUNT_SIDMain account SID β€” used for outbound REST calls (WhatsApp senders are registered on the main account)
TWILIO_API_KEYMain account API Key SID β€” used to authenticate outbound sends
TWILIO_API_SECRETMain account API Key Secret β€” paired with TWILIO_API_KEY
TWILIO_AUTH_TOKENMain account Auth Token β€” preferred token for inbound signature verification
TWILIO_SUBACCOUNT_SIDPer-env subaccount SID
TWILIO_SUBACCOUNT_TOKENPer-env subaccount Auth Token β€” fallback signature verification when sender is owned by the subaccount
TWILIO_WEBHOOK_BASE_URLPublic base URL for this env (e.g. https://api.h.work)

For full setup, see docs/ops/TWILIO-SETUP-CHECKLIST.md.


Slack​

Status​

βœ… Live (Connect flow + manifest paste). Per-org credentials are stored encrypted in integration_credentials; the env-var fallback (SLACK_SIGNING_SECRET, SLACK_BOT_TOKEN) is dev-only and disallowed in production (#1219, BLK-004).

  • Module location: api/src/channels/slack/{slack.controller,slack.service,slack-engagement.service,slack-search.service}.ts β€” extracted from channels.controller.ts in #1212 / #1158.
  • Client Connect card: /client/admin/channels/slack β€” flipped Coming-Soon β†’ Available in #816 (closes #706).
  • Manifest suggester: GET /orgs/:orgId/channels/slack/manifest returns a paste-ready Slack App manifest scoped to the org's webhook base URL (#1034).
  • "Add to Slack" OAuth install: #1498 lands the OAuth-based install flow (one-click "Add to Slack" button + redirect handler that exchanges the code for a bot token and stores it in integration_credentials). Operational runbook: docs/runbooks/slack-oauth-install.md for the manifest paste flow vs. the OAuth install flow.
  • Mention-gated channel ingest: the Slack adapter only ingests channel messages that mention the bot; full-channel ingest is gated behind the slack_engaged_threads book-keeping. Off-mention chatter is dropped at the adapter (#818). Thread context is cached in channel_context_messages for the agent; an hourly retention sweep prunes inactive threads.

Architecture​

Slack event (app_mention or message in an engaged thread)
β”‚
β–Ό
POST /channels/slack/events (SlackController, api/src/channels/slack/)
β”‚ HMAC-SHA256 signature verification using per-org signing_secret
β”‚ resolved from integration_credentials (SlackService.resolveOrgId)
β”‚ 5-minute replay window
β–Ό
SlackEngagementService β€” decide ingest vs. drop (mention-gated)
β”‚
β–Ό
ConversationsService.findOrCreateByChannel()
β”‚ customerId = "slack:{teamId}:{channelId}"
β–Ό
Agent processes β†’ response posted to Slack channel (per-OSA impersonation)
  • Returns 200 immediately (async processing)
  • Handles url_verification challenge for Slack App setup
  • Replay attack protection via X-Slack-Request-Timestamp
  • Outbound supports media attachments per #669; Slack outbound media is wired through ExpertReplyDto.attachments in #1318 (closes #1157) β€” see Reliability primitives below.

Configuration Keys (per-org, stored in integration_credentials)​

KeyDescription
bot_tokenSlack Bot OAuth token (xoxb-...) β€” env var SLACK_BOT_TOKEN is dev-only fallback
signing_secretFrom Slack App settings β€” env var SLACK_SIGNING_SECRET is dev-only fallback
app_idOptional β€” for multi-app routing

Telegram​

Architecture​

  • POST /channels/telegram/webhook β€” receives Bot API updates
  • Validates X-Telegram-Bot-Api-Secret-Token header (HMAC-SHA256)
  • Routes to conversation system

Configuration Keys​

KeyDescription
bot_tokenTelegram Bot token
webhook_secretOptional HMAC secret for webhook validation

Status: Live. Implemented in fix/issue7-telegram branch, merged to master.


Web (embeddable widget)​

The web channel (channel: "web") is an embeddable, customer-facing chat widget a client drops onto their own website. Unlike the other channels β€” which are inbound webhooks/polling against vendor APIs β€” this one runs in the visitor's browser and talks to the h.work API directly. It is the only channel where the end visitor is anonymous (no vendor-side identity) and is authenticated by a short-lived visitor JWT rather than a provider signature.

Architecture​

  • Standalone JS bundle @humanwork/chat-widget (packages/chat-widget/), embedded via <script> on the client site.
  • POST /public/widget/session mints a 24h visitor JWT for { orgSlug, visitorId? } (@Public(), 30 req/min, wildcard CORS). Unknown org β†’ 404; malformed visitorId β†’ 400.
  • The widget then uses the standard conversation API as a client-role caller (GET/POST /conversations, GET /conversations/:id, POST /conversations/:id/messages) and the /notifications Socket.IO namespace for realtime (agent_message_sent, agent_typing, join_conversation/leave_conversation).
  • Responses are masked by ClientConversationViewInterceptor (#2353) so a visitor cannot distinguish a human Expert from the AI.

Auth Method​

LayerMechanism
Session mintPOST /public/widget/session β†’ JWT (role: "client", widgetVisitorId, single-org orgMemberships, 24h TTL, #2925)
API callsAuthorization: Bearer <visitor-jwt>; auto-refresh near expiry, transparent re-mint on 401
Visitor identityper-org UUID persisted in browser localStorage (humanwork.chat.visitorId.{orgSlug})

Status: Live. Originally PR #926 (closes #43); session auth + realtime added since. Full details in the feature spec; embedding instructions (CORS/CSP, script tag, theming) in the embed integration guide.


Teams (Microsoft)​

Transport is implemented end to end and already routes through the V2 session runtime; it has never run against a real tenant. The earlier "NOT_STARTED / stub" description understated what is built β€” see the Teams integration parity plan for the verified baseline, the gap analysis against Slack, and the phased plan.

  • POST /channels/teams/webhook β€” Bot Framework activity handler
  • POST /channels/teams/messages β€” same validation and routing path
  • Bot Framework JWT validation: complete and tested (channels/teams/teams-jwt.validator.ts)
  • Inbound routing: sessionsService.resolveOrMintForChannel({ source: "teams" }) β†’ dispatchInbound
  • Outbound: channel-dispatcher.service.ts dispatchTeams() β€” AAD token + Connector API replyToActivity

Status: NOT_LAUNCHED. Blocking gaps before it can be enabled, in order:

GapSeverity
JWT validation fails open when no app id resolvesP0 β€” same shape as #2112
No inbound dedup and no bot-message filter (duplicate turns, echo loop)P0
Tenant→org resolution scans every Teams credentialP0
No thread model β€” every channel message collapses into one sessionP1
No mention-gating or engagement TTL β€” the bot answers every messageP1
No attachment support (no normalizeTeams)P1

Issue drafts: teams-integration-issues.md. Setup runbook: TEAMS_SETUP.md (BLK-032).


WeChat​

Stub implementation only.

  • GET /channels/wechat/webhook β€” verification challenge handler
  • POST /channels/wechat/webhook β€” inbound stub (XML parsing not implemented)

Status: NOT_STARTED (BLK-042, P3). Requires WeChat Official Account registration.


Channel Admin Endpoints​

These endpoints provide operational visibility for org admins:

EndpointDescription
GET /orgs/:orgId/channels/statusHealth status for all configured channels
GET /orgs/:orgId/channels/webhook-urlsExact webhook URLs to paste into channel dashboards

Integration Credentials Management​

All per-org channel credentials are stored encrypted in integration_credentials (AES-256-GCM). API responses mask credential values (first 4 chars + ****).

EndpointDescription
POST /orgs/:orgId/integrationsCreate or upsert channel credentials
GET /orgs/:orgId/integrationsList integrations (masked)
DELETE /orgs/:orgId/integrations/:idRemove integration
POST /orgs/:orgId/integrations/:id/testTest connection (lightweight API call)

Reliability Primitives (cross-channel)​

These primitives apply to all live channels (Email/WhatsApp/Slack/Telegram). They were factored out of the channel-specific code paths during the 2026-05 reliability sweep.

PrimitiveSourceDescription
CircuitBreaker#848Per-channel breaker that opens on consecutive transport failures; outbound dispatch fails fast while the breaker is open and emits a metric
Channel DLQ + retry worker#850 (closes #744)Failed inbound webhooks land in channel_dead_letter; the retry worker re-drives them with exponential backoff
Prometheus metrics#1023 (closes #757)DLQ depth, outbound BullMQ queue depth + failure rate, breaker state, dedup hits β€” all exported under /metrics
Outbound dispatch BullMQ queue#936Outbound expert replies route through a BullMQ queue instead of setImmediate; pod restarts no longer lose in-flight sends
DB-level dedup constraints#929Unique indexes on (channel, channel_message_id) enforce dedup at the database layer regardless of in-process Redis cache state
Per-channel outbound media#669 (closes #559); #1318 wires Slack outbound attachments through ExpertReplyDto (closes #1157)Email / WhatsApp / Slack / Telegram all accept DispatchExpertReplyParams.attachments; the channel adapter handles native attachment APIs (Slack files.upload, Twilio MediaUrl, Gmail multipart, etc.)

Roadmap​

ChannelTimelineNotes
WeChatP3Requires WeChat Official Account
TeamsP1Built, not launched β€” see the parity plan; BLK-032 clears at launch