Chat Inbound: Human Replies Re-Enter Tasks¶
The agent-facing chat tools (chat_messages / chat_directory) are
send/read only, so a human reply on the chat platform never re-enters the
task that asked for it. Chat inbound closes that loop: a long-running Slack
Socket-Mode connection consumes mentions, direct messages, thread replies,
and reactions, and routes each one back to the parked approval it answers, so a
conversation completes without the human ever touching the dashboard.
The feature is off by default (tools.chat_inbound_enabled = false) and
inert until an operator also points tools.chat_inbound_connection at a
Slack connection holding a Socket-Mode app-level token (xapp-...). It adds
no public ingress: Socket-Mode is an outbound WebSocket to Slack.
Layering¶
Everything lives under integrations/chat_api/inbound/, split so the I/O
sits behind pure, unit-testable seams:
| Module | Responsibility |
|---|---|
models.py |
InboundChatEvent (vendor-neutral event) + its kind |
decode.py |
pure Socket-Mode frame -> event (no I/O) |
socket_mode.py |
the WebSocket transport (open + stream + ack) |
registry.py |
(channel, thread_ts) -> approval_id correlation |
router.py |
event -> approve/reject decision -> resume dispatch |
consumer.py |
the kill-switched long-running loop + reconnect |
The concrete resume dispatcher (api/chat_inbound_resume.py) lives in the
api layer because it drives the approval flow; the router depends only on
the ChatResumeDispatcher protocol, so the integrations package stays
free of any engine/approval import.
Flow¶
- Correlate on notify. When an approval escalates to Slack, the
SlackNotificationSinkposts the prompt and registers(channel, message_ts) -> approval_idin the boot-scopedInboundThreadRegistry(shared with the consumer). The registry is bounded transient routing state, not domain state; a still-pending approval is re-notified on its own cadence after a restart. - Open + stream. The consumer resolves the connection's app token,
calls
apps.connections.open(host-pinned to slack.com) for a short-livedwss://gateway URL, and streams frames. An event envelope is acknowledged only after its event is dispatched: the resume dispatcher is idempotent (save_if_pendingCAS), so at-least-once delivery (a re-delivery if the process dies between receipt and ack) is strictly safer than losing a human decision. A disconnect envelope is still acked before reconnecting. - Decode.
decode_framemapsapp_mention/message(including direct messages) /reaction_addedontoInboundChatEvent, dropping bot echoes and message subtypes (edits/joins). A top-level message roots its own thread for correlation. - Route + resume. The router resolves the event's thread to an
approval and decides explicit-token-only (see Authorization). The
ApprovalResumeDispatcherrecords the decision atomically (save_if_pending, so a concurrent dashboard decision or a duplicate event cannot double-resume) and hands off tosignal_resume_intent(the same internal entrypoint the dashboard approve/reject endpoint uses), which resumes the parked task through the existing routing. Both writes are bracketed by the resume-intent outbox (see below) so a process death between them cannot strand the parked task.
Crash recovery: the resume-intent outbox¶
Deciding an approval is two writes that cannot be merged into one. The
decision lands on the ApprovalItem, moving it off PENDING; only then
does signal_resume_intent wake the parked task. A process death between
them strands that task forever: nothing is PENDING, so neither a
redelivered Socket-Mode event nor the dashboard can act on it, and no
sweep notices.
api/resume_intent_outbox.py closes that window. Both decision paths (the
dashboard endpoint and this dispatcher) call record_resume_intent before
the decision write and clear_resume_intent once the resume settles, and
ResumeIntentDrain resolves whatever survives at the next startup:
| Approval state at drain | Action |
|---|---|
Absent, or still PENDING |
Discard the marker: the decision never landed, so the approval is still decidable and re-dispatching would invent consent |
| Decided BEFORE the marker was recorded | Discard: the marker was written by a caller that went on to lose save_if_pending, so the resume behind it already completed |
APPROVED / REJECTED, decided at or after the marker |
Re-dispatch signal_resume_intent, then clear |
| Re-dispatch raises | Keep the marker: the task is still parked, so the next startup retries |
Three properties make that table safe under a race, where one caller wins
save_if_pending and the other does not:
- Recording is insert-if-absent, never an insert-or-replace, so the earliest marker (the one that actually brackets the winner's decision) keeps its timestamp and the loser cannot mask it with a later one.
- Only the winner clears. A loser returning 409 leaves the marker alone, because a concurrent winner may own an in-flight resume behind it; the recorded-after-decision row above is what retires a marker no one owns.
- The marker holds only
approval_idandrecorded_at, never a copy of the decision. The approval row stays the system of record, so the drain readsstatus/decided_by/decision_reasonfrom there and the two can never disagree.
Recording is best-effort (a None backend, or an outbox fault, degrades
to a logged no-op) because a run with no database cannot survive a crash
anyway, and forfeiting recovery for one decision beats 500-ing a
serviceable approval.
A dispatch that fails (rather than crashing the process) is rolled back
on both decision paths: the approval is restored to its pre-decision
status and the marker cleared, so the operator can retry immediately
instead of hitting ConflictError until the next restart drains it.
Authorization: explicit-token-only, from a named decider¶
An approval is consent, so it is never inferred from arbitrary human
text. Only an explicit approve/reject reaction (white_check_mark /
x ...) constitutes a decision; a plain text reply (mention / message /
DM) resolves to no decision and is discarded outright, carrying nothing
onward to the resumed agent. This closes the gap where any user who could
post in the notified thread would approve a parked action (including a
destructive one) merely by typing something: the Socket-Mode path now
requires the same explicit affirmative signal the dashboard does, not a
free-text reply.
An explicit signal is still only half of authorisation. The thread
correlation proves which approval an event answers, never that this human
may answer it, so the reacting user must appear in
tools.chat_inbound_deciders (comma-separated Slack user IDs). The check
runs after the decision is resolved but before any write, and is
fail-closed in every direction: an unlisted user, an unset allowlist, an
unwired resolver, and a settings read that fails all deny the decision and
leave the thread correlation in place so an authorised decider can still
act. Without it, membership of the notified channel would be the whole
authorisation model for a governed action.
Kill-switch + resilience¶
The consumer is a resident start()/stop() service wired like
WebhookEventBridge (constructed at boot, started in the on-startup runner,
drained at shutdown). Its loop reads tools.chat_inbound_enabled live per
iteration and fail-safes to DISABLED: an inbound control surface must
never self-enable on a settings outage. Reconnect-on-drop is delegated to
the shared GeneralRetryHandler (never a hand-rolled backoff); a single
malformed event is isolated so it cannot drop the socket.
SEC-1: fencing¶
Inbound human content is attacker-controlled. The inbound package never
turns it into a prompt: the router forwards a decision's reason only as
a resume decision_reason, which build_resume_message fences with
wrap_untrusted(TAG_TASK_DATA, ...) before any LLM boundary: the exact
path the dashboard approval comment takes, so there is one fencing site,
not two. (Under explicit-token-only the reason is a fixed string rather
than raw human text, but the fenced hand-off remains the single escape
path.) The check_chat_inbound_fenced.py gate enforces this structurally:
no inbound module may call an LLM-completion chokepoint, and
InboundResumeRouter.route must keep the decision_reason= keyword on its
own reachable self._dispatcher.resume(...) call. Binding the check to that
one call is what makes it load-bearing: a decision_reason= sitting on a
helper, on a different collaborator, or after an early return would satisfy
a module-wide search while the real hand-off shipped the text unfenced.