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()
โ”‚
โ–ผ
Expert queue for review
โ”‚
โ–ผ
Expert approves โ†’ Resend API sends reply 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 polling (per-OSA Workspace mailboxes) โ€” #1722โ€‹

2026-06-05 status: Gmail API polling shipped via PR #1722 (commit d8333558, closes #1060). This supersedes the original Pub/Sub design (#1432) and is now the primary inbound path. The Cloudflare relay (System 2) remains as a fallback during a 2-week prod soak; tracking issues #1440 and #1441 stay open until retirement.

For OSAs that have a provisioned Google Workspace mailbox, inbound email is fetched via the Gmail API on a polling cron schedule (*/30 * * * * * on Railway dev โ€” every 30 seconds). The earlier Pub/Sub push design was dropped in favour of pull-mode polling for operational simplicity (no webhook to maintain, no Pub/Sub topic lifecycle, idempotent on retry).

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)โ€‹

Stub implementation only. Bot Framework connector is wired but not tested.

  • POST /channels/teams/webhook โ€” handler exists, marked // needs-testing

Status: NOT_STARTED (BLK-032). Requires Microsoft App registration + Bot Framework credentials.


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
TeamsP2BLK-032 โ€” needs Microsoft App registration + Bot Framework