Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ slack:
# allow_channels: ["eng-*", "C0456DEF"]
# deny_channels: ["*-random", "*-social"]
#
# Let an agent post to a conversation it names (send_channel_message),
# including from a cron run with no chat attached. Off by default and
# separate from the read grant above: allow_channels is set by nearly every
# deployment for inbound access, so deriving writes from it alone would open
# unprompted posting on upgrade. Turning this on never widens where writes
# may go — allow_channels still bounds that, and DMs are always refused.
# While this is off, the send_channel_message tool is not offered at all.
# allow_outbound: false
#
# Every shared-channel thread is its own session; DMs use one conversation.
#
# How a reply appears while the agent works. "partial" posts a placeholder
Expand Down
26 changes: 26 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,32 @@ warning. Slack in the list costs nothing while Slack is off.
Names and globs are not resolved for the `slack_channel_id` fallback. Without
a literal channel ID, delivery is skipped with a warning.

### Outbound Slack message tool

The `send_channel_message` tool posts to a given conversation. That makes it usable
from a cron run with no conversation attached.

It is **off by default**: `slack.allow_outbound: true` enables the capability,
and `slack.allow_channels` then bounds where it may go. The target must be a literal conversation id.

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `slack.allow_outbound` | bool | `false` | Let an agent post to a conversation it names |

While no channel is both enabled and outbound-enabled, the tool is not offered
to the agent at all. The check reads live config per session, so a reload adds
or removes it.

Three differences from the inbound policy:

- **`slack.allow_users` has no effect.** It says who may drive the
agent, not where the agent may broadcast.
- **Unsolicited DMs are refused**, even with `slack.allow_direct_messages`.
**Group DMs count.** A `G` id is either a legacy private channel or a
multi-person DM, and only `conversations.info` can tell them apart, so a
`G` target costs one cached lookup; a lookup that cannot answer is refused
rather than guessed.

## Quiet Hours

| Key | Type | Default | Description |
Expand Down
23 changes: 22 additions & 1 deletion nerve/agent/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,30 @@ def validate_resume_target(self, native_id: str, cwd: str) -> bool:
...

def excluded_tools(self) -> set[str]:
"""Nerve-registry tool names NOT to expose for this backend."""
"""Nerve-registry tool names NOT to expose for this backend.

Union the runtime's own exclusions with
:func:`config_excluded_tools`, which every backend shares.
"""
...

async def validate_model(self, model: str) -> None:
"""Raise :class:`BackendError` when *model* cannot be served."""
...


def config_excluded_tools(config: Any) -> set[str]:
"""Registry tools the configuration leaves nothing to serve.

Separate from a backend's own exclusions, which turn on the runtime
rather than the config. Read per session, so a reload takes effect
without a restart.

A tool that is offered but can only refuse costs a turn to find that
out. ``send_channel_message`` is the case: outbound is off by default,
and with it off no destination is reachable.
"""
excluded: set[str] = set()
if not config.outbound_channels:
excluded.add("send_channel_message")
return excluded
3 changes: 2 additions & 1 deletion nerve/agent/backends/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
SessionSpec,
TransportDiedError,
TurnInput,
config_excluded_tools,
)
from nerve.agent.backends.images import validate_image_data, validate_image_file
from nerve.agent.cache_policy import cache_ttl_env
Expand Down Expand Up @@ -410,7 +411,7 @@ def excluded_tools(self) -> set[str]:
# ScheduleWakeup is a Claude CLI built-in (captured via the
# PostToolUse hook) — the registry equivalent exists for backends
# without built-ins and would be a confusing duplicate here.
return {"schedule_wakeup"}
return {"schedule_wakeup"} | config_excluded_tools(self.config)

def validate_resume_target(self, native_id: str, cwd: str) -> bool:
"""Check whether Claude Code still has the conversation .jsonl
Expand Down
3 changes: 2 additions & 1 deletion nerve/agent/backends/codex/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
SessionSpec,
TransportDiedError,
TurnInput,
config_excluded_tools,
)
from nerve.agent.backends.codex.appserver import (
CodexAppServerClient,
Expand Down Expand Up @@ -159,7 +160,7 @@ def default_model(self, source: str) -> str:
return self.codex.model

def excluded_tools(self) -> set[str]:
return set()
return config_excluded_tools(self.config)

async def validate_model(self, model: str) -> None:
"""Reject obvious cross-backend model leakage before spawning Codex.
Expand Down
8 changes: 5 additions & 3 deletions nerve/agent/tools/claude_sdk_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,11 @@ def build_session_mcp_server(
ask_user/react/etc. always reference the correct session — no shared
global, no race under concurrent sessions.

``exclude`` drops tools by name — used by agent backends to hide
tools that duplicate a runtime built-in (e.g. the Claude backend
excludes ``schedule_wakeup``; the CLI's ScheduleWakeup covers it).
``exclude`` drops tools by name — used by agent backends to hide tools
that duplicate a runtime built-in (e.g. the Claude backend excludes
``schedule_wakeup``; the CLI's ScheduleWakeup covers it) and ones the
config leaves nothing to serve (see
:func:`~nerve.agent.backends.base.config_excluded_tools`).

The returned dict matches the SDK's ``McpSdkServerConfig`` shape;
``alwaysLoad`` is set to ``True`` so the Claude Code CLI skips tool-
Expand Down
82 changes: 79 additions & 3 deletions nerve/agent/tools/handlers/notifications.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
"""Notification tool handlers — notify, ask_user, propose_action, react, send_sticker, send_file.
"""Notification tool handlers — notify, ask_user, propose_action, react,
send_sticker, send_file, send_channel_message.

All six tools need ``ctx.session_id`` so the channel router can deliver
to the correct chat (web, Telegram). The session_id arrives via
Most of these need ``ctx.session_id`` so the channel router can deliver to
the correct chat (web, Telegram). The session_id arrives via
:class:`ToolContext`; there's no per-tool special-casing left.

``send_channel_message`` is the exception: it addresses a conversation the
caller names, so it never reads the session's message context. That is what
makes it usable from a cron run, and it is why the destination has to clear
the channel's own write policy first.

``propose_action`` files an ``approval``-kind notification whose answer
routes through a server-side dispatcher (``ctx.notification_service``)
instead of being injected back into the originating session.
Expand All @@ -29,6 +35,7 @@
NOTIFY_SCHEMA,
PROPOSE_ACTION_SCHEMA,
REACT_SCHEMA,
SEND_CHANNEL_MESSAGE_SCHEMA,
SEND_FILE_SCHEMA,
SEND_STICKER_SCHEMA,
)
Expand Down Expand Up @@ -438,6 +445,58 @@ async def send_file_handler(ctx: ToolContext, args: dict) -> ToolResult:
)


async def send_channel_message_handler(ctx: ToolContext, args: dict) -> ToolResult:
"""Post to a conversation the caller names, on the transport it names.

The target is passed straight through and never inferred from the
session's last inbound message, so this works from a cron run with no
chat context — and cannot silently retarget a different conversation
when it does have one.

A policy refusal comes back as plain text, not ``is_error``: the agent
asked a reasonable question and got a reasoned "no", which is an answer
rather than a malfunction. A transport failure *is* ``is_error``, so
turn telemetry does not record a message that never arrived as a
successful call.
"""
if not ctx.engine:
return ToolResult.text("Engine not available.", is_error=True)

channel = args.get("channel", "").strip()
target = args.get("target", "").strip()
text = args.get("text", "")

if not channel:
return ToolResult.text("Error: channel is required.", is_error=True)
if not target:
return ToolResult.text("Error: target is required.", is_error=True)
if not text.strip():
return ToolResult.text("Error: text is required.", is_error=True)

try:
decision = await ctx.engine.router.deliver_addressed(
channel, target, text, session_id=ctx.session_id,
)
except Exception as e:
logger.error("send_channel_message dispatch failed: %s", e)
# A long message is split into several posts, so a failure partway
# through leaves the earlier parts delivered. Say so: a plain
# "failed" reads as "nothing happened" and invites a retry that
# posts those parts a second time.
return ToolResult.text(
f"Failed to send message on {channel}: {e}. If the message was "
f"long, earlier parts of it may already have been posted — check "
f"the conversation before retrying.",
is_error=True,
)

if decision.allowed:
return ToolResult.text(f"Message sent to {channel} target {target}.")
return ToolResult.text(
f"Refused: cannot send to {channel} target {target} — {decision.reason}"
)


NOTIFY_SPEC = ToolSpec(
name="notify",
description=(
Expand Down Expand Up @@ -527,6 +586,22 @@ async def send_file_handler(ctx: ToolContext, args: dict) -> ToolResult:
handler=send_file_handler,
)

SEND_CHANNEL_MESSAGE_SPEC = ToolSpec(
name="send_channel_message",
description=(
"Post a message to a chat conversation you name, on the transport "
"you name — e.g. a Slack channel. Unlike 'notify', this does not go "
"to the user's notification inbox, and unlike a normal reply it is "
"not tied to the current chat, so it works from a cron run with no "
"conversation attached. The destination must be approved by that "
"channel's write policy (Slack: it must match slack.allow_channels); "
"a refusal comes back with the reason. Unsolicited direct messages "
"are not supported."
),
input_schema=SEND_CHANNEL_MESSAGE_SCHEMA,
handler=send_channel_message_handler,
)


NOTIFICATION_SPECS = [
NOTIFY_SPEC,
Expand All @@ -536,4 +611,5 @@ async def send_file_handler(ctx: ToolContext, args: dict) -> ToolResult:
REACT_SPEC,
SEND_STICKER_SPEC,
SEND_FILE_SPEC,
SEND_CHANNEL_MESSAGE_SPEC,
]
26 changes: 26 additions & 0 deletions nerve/agent/tools/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,32 @@
"required": ["file_path"],
}

SEND_CHANNEL_MESSAGE_SCHEMA = {
"type": "object",
"properties": {
"channel": {
"type": "string",
"description": (
"Transport to send through, e.g. 'slack'. This is not the "
"conversation — that is 'target'."
),
},
"target": {
"type": "string",
"description": (
"Conversation to post to, in that transport's own addressing. "
"Slack: a conversation id such as 'C0123ABCD', or "
"'C0123ABCD:1700000000.000100' to reply inside a thread."
),
},
"text": {
"type": "string",
"description": "Message body, in Markdown.",
},
},
"required": ["channel", "target", "text"],
}

# ----- MCP admin tools -----

NERVE_API_SCHEMA = {
Expand Down
19 changes: 19 additions & 0 deletions nerve/channels/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from enum import Flag, auto
from typing import Any

from nerve.channels.access import Decision


class ChannelCapability(Flag):
"""Capabilities a channel can declare.
Expand Down Expand Up @@ -191,6 +193,23 @@ async def send_interaction(
For Web, this is a JSON event over WebSocket.
"""

# ------------------------------------------------------------------ #
# Optional: addressed delivery #
# ------------------------------------------------------------------ #

async def authorize_outbound(self, target: str) -> Decision:
"""Whether an agent may send an unsolicited message to *target*.

Addressed delivery is the one path where the destination comes from
the agent rather than from a person who wrote in first, so the write
policy belongs to the channel that knows what a target means. The
router asks; the channel decides.

Refusing by default matches :meth:`send_file`, which declines rather
than infer a destination. A channel opts in by overriding this.
"""
return Decision(False, f"{self.name} does not accept addressed delivery")

# ------------------------------------------------------------------ #
# Optional: file delivery #
# Only called if channel declares ChannelCapability.SEND_FILES. #
Expand Down
38 changes: 36 additions & 2 deletions nerve/channels/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from nerve.agent.interactive import get_handler
from nerve.agent.streaming import broadcaster
from nerve.channels.access import Decision
from nerve.channels.base import (
BaseChannel,
ChannelCapability,
Expand Down Expand Up @@ -498,19 +499,52 @@ async def deliver(
) -> None:
"""Deliver a complete message to a channel target.

Used by cron jobs and other non-interactive output delivery.
Used by cron jobs and other non-interactive output delivery, which
have no return value to read — see :meth:`deliver_addressed` for the
same send with the refusal reason attached.
"""
await self.deliver_addressed(channel_name, target, message, session_id)

async def deliver_addressed(
self,
channel_name: str,
target: str,
message: str,
session_id: str | None = None,
) -> Decision:
"""Deliver to a target the caller names, reporting why if refused.

The target is always supplied by the caller and never inferred from
``_message_context``, so a cron run cannot spill into whatever chat
last touched the session — the same reasoning as :meth:`send_file`.

The channel authorizes the destination first, because only it knows
what a target means; see :meth:`BaseChannel.authorize_outbound`.
Returning the :class:`~nerve.channels.access.Decision` lets a caller
say why nothing was sent instead of only that nothing was.

A transport failure propagates rather than becoming a refusal: "the
policy said no" and "Slack was down" are different answers.
"""
channel = self._channels.get(channel_name)
if not channel:
logger.warning("Cannot deliver to unknown channel: %s", channel_name)
return
return Decision(False, f"unknown channel {channel_name!r}")

verdict = await channel.authorize_outbound(target)
if not verdict.allowed:
logger.info(
"Refused addressed delivery to %s: %s", channel_name, verdict.reason,
)
return verdict

formatted = channel.format_response(message)
await channel.send(OutboundMessage(
target=target,
text=formatted,
session_id=session_id or "",
))
return verdict

# ------------------------------------------------------------------ #
# Streaming adapter lifecycle #
Expand Down
Loading
Loading