Skip to content

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

  1. Correlate on notify. When an approval escalates to Slack, the SlackNotificationSink posts the prompt and registers (channel, message_ts) -> approval_id in the boot-scoped InboundThreadRegistry (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.
  2. Open + stream. The consumer resolves the connection's app token, calls apps.connections.open (host-pinned to slack.com) for a short-lived wss:// gateway URL, and streams frames. An event envelope is acknowledged only after its event is dispatched: the resume dispatcher is idempotent (save_if_pending CAS), 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.
  3. Decode. decode_frame maps app_mention / message (including direct messages) / reaction_added onto InboundChatEvent, dropping bot echoes and message subtypes (edits/joins). A top-level message roots its own thread for correlation.
  4. Route + resume. The router resolves the event's thread to an approval and decides explicit-token-only (see Authorization). The ApprovalResumeDispatcher records the decision atomically (save_if_pending, so a concurrent dashboard decision or a duplicate event cannot double-resume) and hands off to signal_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_id and recorded_at, never a copy of the decision. The approval row stays the system of record, so the drain reads status / decided_by / decision_reason from 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.