Digital Worker Team Chat Observer Design

Phase 1 of the Digital Worker product: a Team Chat Observer that integrates directly with WhatsApp, Telegram, and Discord to answer @-mentions in-channel and draft follow-up tasks from observed commitments into a review inbox. It enforces verified identity, per-channel capability policy, a two-tier LLM (local triage then gated remote), and human approval only for consequential actions — with Fieldforce as the sole task-promotion target.

View source markdown ↗ generated by claude-opus-4-8 · diagrams mermaid

Digital Worker — Phase 1 Team Chat Observer Design

Summary

  • Product Digital Worker — Phase 1 chat observer
  • Providers (MVP) WhatsApp, Telegram, Discord — direct integration
  • Behaviors Mention-response + proactive task drafts
  • AI posture Local triage → gated remote; AccessGate-governed
  • Approval Safe actions auto; consequential actions gated
  • Promotion target Fieldforce task (only task model today)

The first Digital Worker is a Team Chat Observer that lives inside approved team chats. It answers @-mentions directly in-channel and quietly drafts follow-up tasks from real commitments into a review inbox — all behind verified identity, per-channel capability policy, and an AccessGate-governed two-tier LLM. Gremlin integrates directly with the WhatsApp, Telegram, and Discord bot APIs (no middleware), and humans approve only the actions that actually change the system.

Scope & Goals

The MVP proves exactly two behaviors and nothing more:

Mention response

safe · ungated
  • An allowed, mapped user @-mentions the worker.
  • It summarizes context, extracts action items, drafts a task, or suggests next steps.
  • The answer is posted directly back to chat — no approval step.

Task extraction drafts

consequential · gated
  • In configured proactive channels the worker observes chat.
  • It drafts follow-up work from explicit commitments, requests, blockers, handoffs, or due-date language.
  • Drafts land in the Worker Inbox for human review before becoming tasks.

Success criteria

  • An org admin can bind a worker to approved channels and allowed users.
  • A mapped member can mention the worker and receive useful, policy-compliant help.
  • In proactive channels, the worker drafts follow-up tasks into a review inbox.
  • Managers/supervisors can review, edit, approve, dismiss, or promote drafts into Fieldforce tasks.
  • Every event, policy decision, AI generation, approval, dismissal, and promotion is auditable.

Key decisions

Four load-bearing decisions each became an ADR. They are the things a future reader would otherwise read as a bug.

Integrate directly with messenger APIs — no middleware (ADR-0031)

Go core owns one adapter per provider: WhatsApp (Meta Cloud API), Telegram (Bot API), Discord (Bot Gateway). No OpenClaw/Hermes, no Bun bridge.

Middleware adds an external dependency, a hop, duplicated identity/audit, and uncertain transport. The CRM module's Meta WhatsApp client already proves the inbound-webhook + outbound-send pattern works here.

Rejected: OpenClaw/Hermes middleware; Bun/ClawUI-owned worker; a separate worker service.

Act on chat without per-action approval — safe vs consequential (ADR-0029)

Safe-executable actions (answer a solicited mention, summarize, extract, create internal-only drafts) run with no approval; only consequential actions (create/assign tasks, post unsolicited messages, schedule) are human-gated.

Gating "summarize this thread" makes the headline UX useless. The gate exists to stop the worker acting on the org's behalf, not to police answering a question. Everything is recorded for after-the-fact review.

Rejected: approve-everything-up-front (original spec) — safe but kills the product.

Two-tier LLM — local triage gates the gated call (ADR-0028)

A local Ollama model triages every proactive message over a rolling window as an unbilled pre-gate filter; only a positive hit calls the frontier model through AccessGate.

Per-message remote extraction burns caps on chatter and fragments multi-message commitments. This mirrors Fieldforce's deterministic-shortlist → one-gated-call shape, with a learned classifier instead of keywords.

Rejected: per-message remote; batched remote with no pre-filter; keyword pre-filter; routing the local call through the gate.

Extraction watermark with divergent outage recovery (ADR-0030)

A per-channel watermark advances only after a successful pass. Cap/credit/provider outages hold it and backfill on recovery; an explicit feature-flag-off advances it past the gap (no backfill).

Event idempotency stops duplicate processing, not duplicate drafts. The divergence encodes intent: cap-exhaustion is "couldn't pay, recover my work"; flag-off is "stop observing us, don't read the backlog."

Rejected: semantic dedup in MVP; always-backfill on re-enable; no-backfill on cap-exhaustion.

Approach considered

Selected

Go-owned core, direct provider adapters

Keeps identity, policy, approvals, audit, AccessGate, and promotion in the Go backend. One adapter per provider, each with its own transport quirks. No middleware dependency.

Rejected

OpenClaw/Hermes middleware

Offloads provider plumbing but adds an external dependency and a hop, duplicates identity/audit, and leaves transport availability uncertain.

Rejected

Bun/ClawUI-owned worker

Close to chat sessions, but duplicates org identity, permissions, audit, and AI billing concerns for the product core.

Architecture

End-to-end flow: direct provider adapters into a Go core; safe replies go straight back to chat, proactive drafts pass local triage then the gate into the inbox
End-to-end flow: direct provider adapters into a Go core; safe replies go straight back to chat, proactive drafts pass local triage then the gate into the inbox.

Heterogeneous ingest — the key consequence of going direct

The three providers do not share one ingest model, and that decides multi-replica safety.

ProviderIngestSendMulti-replica note
WhatsAppWebhook (stateless, HMAC)Graph API (template / 24h rules)Any replica receives; idempotency key dedups retries
TelegramWebhook (preferred) or long-pollBot API sendMessageStateless like WhatsApp; avoid getUpdates polling in multi-replica
DiscordPersistent Gateway WebSocketREST POST /channels/{id}/messagesOne connection must own ingest — per-replica = duplicate events

The Go core owns organization/user identity, external chat identity mapping, worker bindings, channel allowlists, capability policies, the inbox and approval states, audit logs, AccessGate calls and DB-managed prompts, inbound ingest and outbound send (ChatSender), and task promotion into Fieldforce.

Security & permissions

Chat platforms are untrusted. Display names never grant authority — only a verified mapping between an external chat identity and a Gremlin user does.

Identity uniqueness
UNIQUE (organization_id, provider, external_user_id) — one chat account resolves to one Gremlin user per org; the same account may map independently in another org.
Resolution
Keys on (organization_id, provider, external_user_id) and requires status = 'verified'; any miss → default-deny unknown_user.
Verification (MVP)
Admin assertion in the panel — platform user IDs are stable; a self-service DM-code challenge is deferred.
Capability policy
Per worker/channel: observe, reply-on-mention, proactive-draft, create-inbox-item, promote-task, assign-task, schedule-meeting.
Role-based approval
Authority comes from Gremlin org/team roles, not chat roles.

Two-tier action model

Safe-executable

no approval
  • Answer a solicited mention (reply-on-mention).
  • Read/summarize allowed context; classify/extract.
  • Create internal_only inbox drafts.

Consequential

human-gated
  • Create or assign Fieldforce tasks.
  • Post proactive (unsolicited) chat messages.
  • Schedule meetings; any future external action.
  • New action types default to consequential.

Default-deny: if identity mapping, channel binding, or policy is missing, the worker does nothing except record a denied event for observability.

Proactive extraction & the watermark

Two-tier proactive path: free local triage gates the billed remote call; failures hold the watermark for automatic backfill
Two-tier proactive path: free local triage gates the billed remote call; failures hold the watermark for automatic backfill.

Processing flow

  1. Normalize the event; resolve channel binding; resolve external identity; load capability policy.
  2. Mention-response path (safe, ungated, per-event)

    If mentioned and reply-on-mention is allowed, call the remote model via AccessGate (digital_worker:mention_response), validate, and post the answer directly back to chat. Record to dw_chat_events + dw_ai_runs + an internal_only inbox row. A consequential ask also creates a gated draft.

  3. Proactive-draft path (consequential, gated, two-tier)

    Local triage (unbilled) over a rolling window decides if a commitment is present; only a positive hit calls the remote extractor (digital_worker:chat_task_extract) with the conversation window, coalescing rapid hits into one draft.

  4. Notify approvers via the in-app notifications module (digital_worker:inbox_pending, debounced digest, approver-scoped).
  5. Approver edits, approves, dismisses, or promotes a gated draft to a Fieldforce task.
  6. Audit every step across dw_chat_events, dw_ai_runs, and the shared audit_log.

Watermark + coalescing

Two distinct boundaries: triage reads a context window, but only messages after the watermark can become drafts.

Triage context windowExtraction / draft boundary
PurposeUnderstand whether a line is a commitmentDecide what becomes a draft
RangeLast N messages (may include old ones)Strictly messages after the watermark
Re-reads old messages?Yes (for context)Never (watermark blocks them)
Worked example — channel #ops
M1 09:00 Alice: "the staging deploy is broken again"
M2 09:01 Bob:   "can you handle the deploy tonight?"
M3 09:01 Alice: "yeah, I'll do it after the client call"
M4 09:02 Bob:   "thanks"
M5 09:05 Carol: "lol who's getting lunch"
  1. Cheap ingest each message → dw_chat_events (unverified Carol filtered here).
  2. When M3 lands, triage reads [M1,M2,M3] and flags a commitment.
  3. Debounce so M4 joins the burst; one extraction pass handles all messages after the watermark.
  4. The model returns one draft (Run staging deploy, owner Alice, due tonight, sources [M2,M3]); M1/M4/M5 yield nothing.
  5. Persist the draft, then advance the watermark to M5 — M2/M3 can never re-draft, even though they reappear in later context windows.

Message edits & deletes

Edits/deletes arrive as distinct events (eventType: "edit" | "delete", editOfMessageId) referencing the original message. The blind spot: an edit below the watermark won't re-extract on its own.

  • Pending draft → always mark needs_review (free); local triage judges materiality; only a material edit (commitment/owner/due-date) spends a remote re-extraction. Typos get the flag only.
  • Promoted draft → never silently mutate the Fieldforce task; flag for the manager.
  • If the transport doesn't deliver edits, drafts reflect extraction-time state (known per-transport limit).

AI output schemas

Both gated calls return strict JSON. On schema failure: drop invalid fields, retry once, then store a failure audit.

digital_worker:chat_task_extract — one item per coalesced commitment:

{
  "title": "<short imperative task title>",
  "summary_md": "<one-paragraph manager-facing context>",
  "commitment_type": "commitment | request | blocker | handoff | due_date",
  "suggested_owner_user_id": "<Gremlin user_id of a VERIFIED mapped identity, or null>",
  "due_date": "<ISO-8601 date, or null if no explicit due-date language>",
  "recommended_destination": "internal_only | fieldforce_task",
  "source_message_ids": ["<externalMessageId>", "..."],
  "confidence": 0.0
}

digital_worker:mention_response:

{
  "reply_text": "<message to post in-channel, org locale>",
  "action_requested": "none | create_task",
  "extracted_task": { "...": "same shape as a chat_task_extract item, or null" }
}

Data model

TablePurpose
dw_role_agentsPersona / identity anchor — the thing that acts and owns the AccessGate actor id. Phase 2 seam.
dw_workersProvider-specific bot connection under a role-agent (single provider/account in MVP). Holds no secrets.
dw_worker_credentialsSend token / ingest secret, AES-256-GCM encrypted (reuses BYOK KeyCrypto); never read back.
dw_channel_bindingsWorker → provider/channel scope; proactive mode, capabilities, approval policy, extraction watermark.
dw_external_identitiesVerified external-chat → Gremlin user mapping; per-org unique.
dw_chat_eventsNormalized event log + default-deny outcomes (decision, denied_reason) + raw payload pointer.
dw_inbox_itemsReviewable worker outputs; recommended_destination (AI) vs destination (approver).
dw_inbox_item_sourcesLinks inbox items back to source chat messages.
dw_ai_runsAI provenance (model, prompt version, feature key, gate request id); retains the original AI draft.

Error handling

CaseBehavior
Unknown channel / userStore denied event; do not process or respond.
Missing capabilityDeny the action and record the policy reason.
AccessGate denied / cap exhausted (402)Proactive: no draft, watermark held, retried + backfilled on recovery. Mention: post the generic "couldn't process" notice (send path permitting). No heuristic extraction fallback — it needs the LLM.
BYOK out of credits / provider error (502)Same as cap-denied: no draft, watermark held, batched backfill on top-up.
Invalid AI outputDrop invalid fields, retry once, then store a failure audit.
Local triage model unreachableFail-closed: skip proactive extraction and log. Mention-response unaffected.
Source message edited / deletedPending → needs_review + materiality-gated re-extract. Promoted → flag the manager, never auto-mutate.
Reply fails, send path healthyPost a generic in-channel notice; save internal_only item; record the precise reason internally only.
No / rejected send credentialDo not attempt an in-channel notice (it can't land); degrade to an internal_only draft; surface the real reason to admins.
Duplicate eventIdempotency key on (provider, external_message_id).

Privacy boundaries

  • No proactive observation unless a channel is explicitly configured.
  • Full raw payloads live in short-retention object storage behind rawPayloadRef (never inline in Postgres, never rendered in normal UI — platform-admin debugging only).
  • Per-org retention config via org_module_configs (digital_worker.raw_retention_days, digital_worker.event_retention_days); each org decides its window, default ~14–30 days. No redact-at-ingest — exposure is controlled by access-control + retention. A hard-delete hook fires on unbind / erasure request.
  • No private chats / DMs for proactive analysis unless explicitly enabled later.
  • MVP avoids employee-performance scoring; it extracts work commitments, not personal evaluation.
  • The admin panel makes active worker/channel bindings visible so teams know where the worker is present.

Open questions — all resolved

Transport availability
No middleware; transport is fixed per provider (WhatsApp/Telegram webhook, Discord Gateway). Ingest is heterogeneous.
Default promotion target
Fieldforce only — no generic Gremlin task model exists.
Raw payload storage
Short-retention object storage behind a reference; per-org retention config; no ingest redaction.
Approver notification channel
In-app notifications module (debounced digest); messenger-DM delivery deferred (WhatsApp template/opt-in constraints).

Decisions & ADRs

New ADRs introduced by this design:

ADR-0028
Two-tier LLM — local triage as an unbilled pre-gate filter.
ADR-0029
Worker acts on external chat without per-action approval (safe-executable vs consequential).
ADR-0030
Extraction watermark — advance-on-success, divergent outage recovery.
ADR-0031
Direct messenger integration (no middleware); Discord Gateway single-owner lease.

Builds on existing decisions: ADR-0007 (two audit systems), ADR-0013 / ADR-0014 / ADR-0015 (Fieldforce task model), ADR-0016 (two-tier feature flags), ADR-0023 (lease pattern).