Skip to content

Hosted channels: the email channel - #601

Open
alex-clickhouse wants to merge 7 commits into
alex/hosted-channel-behaviourfrom
alex/hosted-email-source
Open

alex-clickhouse wants to merge 7 commits into
alex/hosted-channel-behaviourfrom
alex/hosted-email-source

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

Stack (merge in this order): #603 generic hosted channel behaviour -> #601 the email channel. The channel gateway that sends the new capabilities members and the email events ships first.


What

  • New setting email.enabled (default false). With usable
    channels.hosted settings, Nerve has an email channel: the channel gateway
    delivers the mail that members of the agent send to its address, and each
    mail starts or continues a session.
  • One session for each sender and email thread (email:<conversation ID>),
    kept however long it was idle. Nerve posts no answer: the agent answers
    with the reply tool that the gateway names, and the turn's text starts
    with a line that says so.
  • The subscriptions frame names email while email.enabled is true, so
    the gateway takes member mail only then. A reload switches email on or off
    without a restart. While it is off, Nerve rejects email events and goes on
    reading the events of the other providers.
  • Without usable channels.hosted settings, email.enabled gives a startup
    warning, the same warning in nerve doctor, and no channel.
  • A reload that no gateway stream can carry reports the subscriptions as
    not sent yet; a stream that connects sends them before Nerve reads from
    it. A reload while email.enabled is true says why there is no email
    channel, or that a restart is needed.
  • A provider without an outbound setting, such as email, refuses
    send_channel_message with a text that names no config key.
  • nerve doctor reports the email channel. Docs: an Email section in
    docs/config.md, the reload table, the hosted mode text,
    docs/architecture.md, and config.example.yaml.

How

  • Email is the second entry of the table of hosted chat providers. Its
    channel exists whenever the hosted settings are usable, and its switch is
    email.enabled, read for each event. It has no settings of its own and
    refuses its events while it is off.
  • A new provider field, subscribes, makes the runtime add an entry with
    the provider's name and empty filters to the subscriptions frame while the
    provider is on. A runtime with such a provider sends the frame on every
    stream, also without hosted sources, and again on each reload. Nerve checks
    the email entry as the gateway does.
  • The turn itself uses the generic connection behaviour: the gateway
    announces the email connection with answer: tool, the reply tool, and
    session: keep.
  • The email event and the email subscriptions frame are copies of the
    channel contract's samples, pinned by their SHA-256.

Why

Member mail is an invocation, not a source record: a member writes to the
agent and expects the agent to act and to answer when it decides to. The
earlier source design for this PR is replaced; it never shipped.

Notes

  • A runtime with hosted Slack and no hosted source now sends an empty
    subscriptions frame on each stream, so that a reload that switches email
    off reaches the gateway. Platform agents always have the hosted GitHub
    source and sent a frame already.
  • While a hosted Slack is switched off, member mail waits in the gateway
    inbox, as GitHub records do.

Tests

New tests/test_hosted_email_channel.py: the contract samples, the
setting, the provider entry, the subscription entry, the email event over a
real stream with the email connection's capabilities (a turn in the
conversation's session, nothing posted, the note line, the session kept past
the sticky period, one session per thread), a switched-off email, the frame
with and without email, a reload that switches email on and off, and
nerve doctor. Startup tests for the email channel and the warning.

🤖 Generated with Claude Code

@alex-clickhouse
alex-clickhouse requested a review from pufit November 1, 2026 23:22
@nerveai

nerveai commented Nov 1, 2026 •

Copy link
Copy Markdown

Nerve PR review · ✅ Ready to merge (after #603)

Updated for dbad161: the PR is rewritten. Member mail is now an invocation channel instead of a source, as the author describes above. It is stacked on #603 and reviewed against that branch.

Email becomes the second entry in #603's hosted provider table (nerve/channels/hosted/providers.py:199). Each member mail is an invoke event. It starts or continues one session per sender and thread (email:<conversation>). The gateway's answer: tool + session: keep behaviour applies, so Nerve posts nothing and the agent replies through the tool the gateway names. This is the right model. Member mail no longer goes through the inbox and a messages-gated job, so both of my earlier notes are moot. The only new mechanism is subscribes. The frame names email only while email.enabled is on (runtime.py:198-216), so the gateway refuses mail before storing it when email is off. Stray events are rejected without holding back the other providers (channel.py:930). A reload switches email on or off. Startup, nerve doctor and reload each say when hosted settings are missing or a restart is needed.

Completeness: a dedicated test file covers pinned contract samples, a real stream, the kept session and one session per thread, the switch, the frame, reload and doctor. Startup tests and docs (config, the reload table, architecture, the example config) are included. No migration.

Checked: 26 related test files (hosted channel/email/behaviour/sources, contract, config reload/validate, gateway startup, drain, attribution, Slack channel) in an offline sandbox at dbad161: 1838 passed. GitHub CI backend tests still running. Merges cleanly into #603's branch and, looking ahead, into main · automated review by nerveai — mention @nerveai to ask a follow-up.

alex-clickhouse and others added 7 commits November 3, 2026 08:17
Member mail arrives from the channel gateway as an email invocation
that starts or continues a session. The generic provider behaviour
does the rest: the email connection answers with a tool and keeps
its sessions, so Nerve posts nothing and the agent answers with the
reply tool.

Email is an entry of the hosted chat provider table. Usable
channels.hosted settings give the channel, also while the new
email.enabled setting is false, and the switch is read per event,
so a reload switches email on or off without a restart. While it is
off, the channel refuses email events and the reader goes on.

The gateway takes mail only for a subscribed agent, so a runtime
with the email channel always sends a subscriptions frame: with the
email entry while email is on, without it while email is off, also
when no hosted source exists. A reload that switches email off then
takes the entry away and the gateway refuses new mail. Without
usable hosted settings, email.enabled gets a startup warning and no
channel.

The C2 event and the email subscriptions frame are copies of the
contract samples, pinned by their SHA-256.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
email.enabled works only with usable channels.hosted settings, and
the startup warning is easy to miss. The doctor shows the same
warning, an error for a key file that cannot be read, and a warning
while a switched-off hosted Slack holds member mail back. While
email is switched off, the doctor output does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/config.md gets an Email section: the email.enabled setting, the
need for usable channels.hosted settings, one session per sender and
email thread that keeps its session, no posted answer and the reply
tool, and how a reload switches email on or off through the
subscriptions. The reload table, the hosted mode text, the connection
behaviour section and the architecture overview name email as the
second hosted chat provider. config.example.yaml shows the setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
send_channel_message to the email channel was refused with
"email.allow_outbound is not enabled", a key that does not exist, so
the agent could tell its user to set it. A provider entry now names
its outbound key (Slack: slack.allow_outbound); a provider without
one is refused with "the <provider> channel does not post to a
conversation that the agent names". The refusal itself and Slack's
text do not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reload that switches email off must take the email entry away at
the gateway. With no open stream, or none that sent the frame in
time, the reload said "published on 0 stream(s)" and counted it as
a success, while the gateway kept the old subscriptions.

republish_subscriptions() now raises SubscriptionsNotSent in that
case, and the reload reports subscriptions as an error that says
they are not sent yet and that a stream that connects sends them
before Nerve reads from it. Tests switch email off and on with no
stream open, reconnect, and check that the new stream sends the
current subscriptions before its first read. Mail that the gateway
stores before it gets them is rejected when Nerve reads it; the docs
say that this is the result of the switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reload that switched email on without usable channels.hosted
settings said nothing: the warning came only at startup and in
nerve doctor. A reload while email.enabled is true now repeats that
warning under email. When the settings are usable but this process
has no email channel, because they became usable after startup or
the hosted runtime did not start, it says so under email and adds a
line to restart_required. Nothing changes while email is off or its
channel runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every stream that connects sends the current subscriptions before
Nerve reads from it, so the gateway always gets them. Reporting a
republish that no stream sent as an error made workspace sync run
the whole reload again on every cycle, and the setup page show a
warning after each save, while the gateway could not be reached.

The reload result keeps the same text ("not sent yet: no gateway
stream is open; ...", or how many open streams did not send them in
time), now as a note that leaves the reload ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse force-pushed the alex/hosted-email-source branch from 1d87838 to dbad161 Compare November 3, 2026 09:54
@alex-clickhouse alex-clickhouse changed the title Hosted email source: members email their agent Hosted channels: the email channel Nov 3, 2026
@alex-clickhouse
alex-clickhouse changed the base branch from main to alex/hosted-channel-behaviour November 3, 2026 09:54
@alex-clickhouse
alex-clickhouse requested a review from pufit November 3, 2026 09:54
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

@nerveai Thanks. That was not the intended behaviour. The PR is now rewritten:

  • Member mail is an invocation, not a source record. Each mail starts or continues one session for the sender and the email thread, like a direct message in a chat channel. It does not go to the source inbox, so no messages-gated job is involved, and worker installs need no inbox processor.
  • The channel posts nothing back. The gateway announces the email connection with answer: tool and session: keep (the generic connection behaviour of Hosted channels: generic provider behaviour (provider table, answer and session modes) #603). The agent answers only when it decides to, with the reply tool that the gateway names. The turn's text starts with a line that says so.
  • The mail text names the sender by principal ID and display name, and says that quoted or forwarded text can be other people's words.

The earlier cursor gap for hosted github/github_repos after a long park is not in scope here.

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.

3 participants