Skip to content

Send hosted Slack replies, files, and notifications through the gateway - #466

Open
alex-clickhouse wants to merge 3 commits into
alex/hosted-channel-intakefrom
alex/hosted-channel-outbound
Open

alex-clickhouse wants to merge 3 commits into
alex/hosted-channel-intakefrom
alex/hosted-channel-outbound

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #465 (alex/hosted-channel-intake); #467 is stacked on this one. The gateway side of these operations is in review in ClickHouse/nerve-private#71, in the stack ClickHouse/nerve-private#63 to ClickHouse/nerve-private#77.

In hosted mode, Nerve now sends its outbound work to Slack through the gateway. Each action is a channel contract operation on a gateway stream. Self-hosted Slack is unchanged.

Why

After #465, a hosted agent can read Slack but cannot answer. This PR gives it the same outbound features as Socket Mode: replies, streaming edits, typing, send_channel_message, send_file, attachments, and notification cards.

What it does

  • OperationRunner sends each operation on a stream that advertised it for the connection, within the stream and connection in-flight limits. The gateway gets the connection's deadline as its budget.
  • When a result is lost, a retry-safe operation is sent again. A side-effecting operation is reported as ambiguous and is never sent again, because it may already be in Slack. A rate-limited operation is sent again after the advised delay.
  • Replies are split at the connection's text limit. Streamed replies use a placeholder, paced edits, and a delete. When a connection that can send lacks edit or delete, there is no placeholder, and the reply is sent once when the turn ends. That path is in the shared StreamAdapter, which sends the buffered reply whenever no placeholder exists. Typing is an eyes reaction on the message that started the turn.
  • While slack.enabled is false, the hosted channel sends no notifications and refuses send_channel_message. A turn that is already running still sends its reply.
  • send_channel_message keeps the slack.allow_outbound switch. When allow_channels or deny_channels is set, the conversation ID must also pass them. The gateway decides in every case, and the agent gets a refusal without the gateway's reason.
  • send_file uploads in chunks after its operation, within the connection's file limit and the gateway's memory limit.
  • Attachments of a message that starts a turn are read through the gateway before the turn, with the Socket Mode rules for text, images, PDF, and ZIP files, and a limit for each file and each message.
  • A notification is a card with one button for each answer, in the same conversation as in Socket Mode. A press answers the notification only in the conversation where it was delivered, and the card then shows the answer without buttons.
  • The gateway closes a stream on any frame that fails its checks. Nerve checks the values that the agent chooses before it sends them: target and message IDs, reaction names, upload file names, button labels and answers, and empty text. A bad value fails one operation, not the stream.
  • The contract adds the operation, result, and transfer records that Nerve uses. A result must answer its operation's kind with a known outcome, and carry the target or transfer that Nerve reads; capabilities must give usable limits.
  • nerve doctor reports the hosted send tool settings and the notification conversation, and says that /nerve commands are not available in hosted mode.

How it was tested

Known gaps

  • send_interaction is not implemented. The engine offers interactive tool prompts to the web channel only.
  • A lost file_send is reported as ambiguous, even when the last chunk was not sent.
  • After a restart, a notification card gets its text from the event content. If the gateway sends no content, the card shows only the answer.
  • An option value longer than 256 bytes gets no button, and hosted mode has no /nerve reply.
  • A press does not check that the pressed message is the delivered card.
  • Without allow_channels or deny_channels, Nerve does not limit where the send tool may post, direct messages included. Socket Mode refuses unsolicited direct messages. The gateway decides.
  • nerve doctor reports settings only, not live streams.

🤖 Generated with Claude Code

@alex-clickhouse
alex-clickhouse force-pushed the alex/hosted-channel-outbound branch 3 times, most recently from e4ace30 to ed4e2b4 Compare September 30, 2026 15:46
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review October 1, 2026 13:54
@alex-clickhouse
alex-clickhouse added this pull request to stack #407 October 2, 2026 18:12
alex-clickhouse and others added 3 commits October 5, 2026 11:25
In hosted mode, Nerve now sends its outbound work to Slack as channel
contract operations on the gateway streams. `OperationRunner` sends each
operation on a stream that advertised it for the connection, within the
stream and connection in-flight limits. The gateway gets the connection's
deadline as its budget. When a result is lost, a retry-safe operation is
sent again, but a side-effecting operation is reported as ambiguous and is
never sent again. A rate-limited operation is sent again after the advised
delay. Failure text names only the operation and the outcome; the gateway's
reason code goes to the log.

- Replies are split at the connection's text limit. Streamed replies use a
  placeholder, paced edits, and a delete. Typing is an `eyes` reaction on
  the message that started the turn. A text whose frame is too large is
  shortened before it is sent.
- `send_channel_message` keeps the `slack.allow_outbound` switch. When
  `allow_channels` or `deny_channels` is set, the conversation ID must also
  pass them. The gateway decides in every case, and a refused message is a
  coarse refusal for the agent.
- `send_file` uploads through `file_send` and a transfer: chunks after the
  operation, sized to the gateway's frame limit, within the connection's
  file limit and the gateway's memory limit. An early result stops the
  chunks.
- Attachments of a message that starts a turn are read through `file_read`
  before the turn and use the Socket Mode rules for text, images, PDF, and
  ZIP files, with a per-file and a per-message limit. A read is sent only
  while it fits Nerve's stream memory limit, and its chunks must continue
  the transfer that its result declared.
- A notification is a card with one button for each answer, in the same
  conversation as in Socket Mode. A press arrives as an `interaction` event,
  answers the notification in the conversation where it was delivered, and
  replaces the card without buttons. A press that answers nothing gets a
  short notice. An expired card is edited without buttons.
- A cancelled sender lets its frame finish, so the other requests of the
  stream are not lost.
- The gateway closes a stream on any frame that fails its checks. So
  Nerve checks the values that the agent chooses before it sends them:
  target and message IDs, reaction names, upload file names, button
  labels and answers, and empty text. A bad value fails one operation, not
  the stream. The contract adds the operation, result, and transfer
  records that Nerve uses. It checks that a result answers its operation
  kind, has a known outcome, and carries the target or transfer that Nerve
  reads, that only a successful file read carries a transfer, that a chunk
  stays inside its transfer, and that capabilities give usable limits.
- `nerve doctor` reports the hosted send tool settings and the notification
  conversation, and says that `/nerve` commands, including `doctor` and
  `restart`, are not available in hosted mode.

The fake gateway serves operations from scripted replies, serves attachment
reads, and collects uploads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hosted channel advertises STREAMING when `stream_mode` is `partial`,
and reports `supports_message_edit=False` when a connection that can send
lacks `edit` or `delete`. The stream adapter then made no placeholder and
buffered the tokens, but at `done` neither branch matched, so a finished
turn sent nothing. Connections that advertise only `send`, or `send` and
`edit`, lost every reply.

At `done`, the adapter sends the buffered response as one message
whenever no placeholder exists, for any mix of capabilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hosted runtime runs also while `slack.enabled` is false, and pauses
intake. `is_available` and `authorize_outbound` did not read the switch,
so notifications and `send_channel_message` still went to Slack while it
was switched off. In Socket Mode, a switched-off Slack sends nothing.

Both read the switch for each call, with intake. A turn that is already
running still sends its reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse force-pushed the alex/hosted-channel-outbound branch from ed4e2b4 to 91d3f76 Compare October 5, 2026 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant