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 failure-tolerant (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.
Observability¶
Every real event gets a signal, so a stuck consumer or a dropped human reply
is visible without re-instrumenting the loop to find out.
CHAT_INBOUND_EVENT_RECEIVED fires at debug on every frame except Slack's
own hello and disconnect protocol-control frames (a volume signal for
actual traffic, not connection upkeep), and CHAT_INBOUND_DECODE_FAILED
fires whenever decode_frame returns a DecodeDropReason, carrying the
reason value and never the payload. Severity splits in two: a reason any
channel member triggers just by using Slack normally (ROUTINE_DROP_REASONS
in decode.py) logs at info, so ordinary chat traffic cannot bury the
warning that means an event was genuinely lost or malformed:
| Reason | Meaning | Level |
|---|---|---|
no_envelope_id |
A fully-decoded, routable event (reaction included) arrived with no envelope id and cannot be acked or routed -- the sharpest case, because it drops a human reply that decoded successfully | warning |
malformed_payload / malformed_event |
The envelope's payload or payload["event"] was not the expected mapping shape |
warning |
validation_failed |
The event failed InboundChatEvent validation |
warning |
bot_authored |
Bot echo, dropped to prevent a feedback loop | info |
message_subtype |
An edit/join/etc. subtype, not a human reply | info |
unroutable_type |
An event type the router has no handler for | info |
missing_attribution |
No user or channel on an otherwise-decodable event | warning |
malformed_reaction |
A reaction_added event with an unexpected shape |
warning |
decode_frame stays pure (it returns the reason; it does not log), so
socket_mode.py's receive loop is the one place that turns a drop into a log
line, and a valid frame still routes normally alongside the log call.
Kill-switch + resilience¶
The consumer is a resident start()/stop() service wired like
OAuthTokenManager (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.
The reason is not the only field that crosses. The router also forwards the
event's user as the resume decided_by, and that field is decoded from the
Socket-Mode payload with no format check, so it is attacker-controlled on the
same terms. build_resume_message therefore fences it under TAG_DECIDER_NAME
and keeps only the approval id and the decision verb in the trusted marker. A
display name needs no delimiter and no invisible code point to read as an
instruction, so sanitisation alone would leave a second prompt channel open
beside the one this section is about.