Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d2b08c8
Add the core Slack channel with access guardrails
alex-clickhouse Aug 23, 2026
6871240
Document Slack channel configuration and operation
alex-clickhouse Aug 23, 2026
17280e8
Verify Slack shares the archive safety bounds
alex-clickhouse Aug 23, 2026
35eaaeb
Remove redundant Slack channel tests
alex-clickhouse Aug 24, 2026
b69c2c8
Condense Slack configuration documentation
alex-clickhouse Aug 24, 2026
fe72dc7
Hot-reload Slack credentials
alex-clickhouse Aug 24, 2026
54ec319
Configure Slack direct messages explicitly
alex-clickhouse Aug 24, 2026
195e3e1
Condense Slack implementation commentary
alex-clickhouse Aug 25, 2026
b9bb20a
Separate Slack access policy from generic matching
alex-clickhouse Aug 25, 2026
36ae8fc
Hot-reload the Slack channel lifecycle
alex-clickhouse Aug 25, 2026
1523005
Handle stub routers during Slack lifecycle reload
alex-clickhouse Aug 25, 2026
4cab25d
Make Slack runtime and thread sessions coherent
alex-clickhouse Aug 25, 2026
459033c
Close two fail-open paths in Slack access control
alex-clickhouse Aug 25, 2026
993ca73
Ignore messages another app wrote itself
alex-clickhouse Aug 25, 2026
e8e7696
Key cached messages by conversation as well as timestamp
alex-clickhouse Aug 25, 2026
d875f76
Keep the Slack lifecycle lock off socket I/O and past shutdown
alex-clickhouse Aug 25, 2026
b5cdcf3
Gate outbound Slack calls on the running generation
alex-clickhouse Aug 25, 2026
faa53ab
Meter streaming edits per Slack conversation
alex-clickhouse Aug 25, 2026
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
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

---

Nerve is a self-hosted runtime for AI agents, built around the [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk). It gives agents everything they need to be useful long-term: persistent memory, scheduled execution, task management, learnable skills, and channels to reach you through — web UI, Telegram, or autonomous cron jobs.
Nerve is a self-hosted runtime for AI agents, built around the [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk). It gives agents everything they need to be useful long-term: persistent memory, scheduled execution, task management, learnable skills, and channels to reach you through — web UI, Telegram, Slack, or autonomous cron jobs.

Ship a **personal assistant** that develops a personality, remembers your preferences, and manages your inbox. Or deploy a **worker agent** that monitors your CI, reviews PRs, and fixes flaky tests — all plan-driven with human approval. Same engine, different mission.

Expand Down Expand Up @@ -122,6 +122,18 @@ Powered by `python-telegram-bot` v21+.
- `/reply` command for free-text answers
- Configurable DM policy (`open` or `pairing`)

### 💬 Slack Bot

Reach your agent where your team already works — a direct message, or a channel
you have invited it to. Nothing to expose: it connects outwards, so it runs
behind NAT with no public URL.

- Answers appear as they are written, and stay in the thread you started
- Each thread is its own conversation, so several people can work in one channel
- In a channel it stays quiet until you `@mention` it
- Reply to its questions by pressing a button, or send it a file to read
- You decide who may talk to it and where — by person, by channel, or both

### ⏰ Cron Jobs

Scheduled AI sessions via APScheduler. Three session modes:
Expand Down Expand Up @@ -231,7 +243,8 @@ nerve (single Python process)
├── Channels
│ ├── Web — passive WebSocket channel
│ └── Telegram — bot with streaming + inline keyboards
│ ├── Telegram — bot with streaming + inline keyboards
│ └── Slack — bot with streaming + buttons, per-thread sessions
├── Cron (APScheduler)
│ ├── AI jobs (isolated / persistent / main session modes)
Expand Down
28 changes: 28 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,34 @@ telegram:
# allowed_users: [123456789] # numeric Telegram user IDs (or pair instead)
stream_mode: partial # "partial" (edit messages) or "full" (wait for complete)

slack:
# Omitting this key leaves Slack off until both tokens below are set, so an
# install that does not use Slack never has to say so. Set it to true to be
# told about a missing token instead of having the channel stay down.
enabled: false
# Socket Mode needs both tokens in config.local.yaml; no public URL is needed:
# bot_token: xoxb-… (OAuth & Permissions → Bot User OAuth Token)
# app_token: xapp-… (Basic Information → App-Level Tokens, connections:write)
#
# Access patterns match Slack IDs or names, case-insensitively with globs.
# Deny wins, DMs require explicit opt-in, and no allow grant refuses everyone.
# Grant users by member ID, handle, or email only: a member edits their own
# display and full name, so allow_users never matches those.
# allow_users: ["U0123ABC", "alex.soffronow"]
# deny_users: ["*-bot"]
allow_direct_messages: false
# allow_channels: ["eng-*", "C0456DEF"]
# deny_channels: ["*-random", "*-social"]
#
# Every shared-channel thread is its own session; DMs use one conversation.
stream_mode: partial # "partial" (edit messages) or "full" (wait for complete)
# `/nerve` defaults to new, stop, star, unstar, and reply.
# omitted — new, stop, star, unstar, reply
# [] — no slash commands at all
# [all] — everything, including doctor and restart
# doctor/restart affect the host; sessions lists other channels. Opt in.
# commands: [sessions, new, stop, reply]

# Quiet hours (local timezone)
quiet_start: "02:00"
quiet_end: "12:00"
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@ Abstract communication layer with three components:
- **ChannelRouter** — centralized session resolution, streaming adapter lifecycle, interactive tool routing, and cron output delivery. Replaces per-channel session management.
- **StreamAdapter** — translates `StreamBroadcaster` events into channel-appropriate output (edit-in-place for Telegram, accumulated send for simple channels). Created per inbound message.

- **Access matching** (`access.py`) — transport-neutral identity aliases and fail-closed allow/deny pattern matching. Channels compose these primitives into their own policy before creating an `InboundMessage`.
- **Archives** (`archives.py`) — bounded one-level ZIP unpacking shared by Telegram and Slack. The download cap is on compressed bytes, so entry count, per-entry and aggregate uncompressed size, and compression ratio are all checked against the archive directory before an entry is read.

Implementations:
- **Telegram** — python-telegram-bot v21+ with partial message streaming (edit-in-place, 1.5s rate limit), inline keyboard buttons for notification questions, `/reply` command for free-text answers
- **Slack** — slack_sdk Socket Mode (outbound WebSocket, no public URL) with partial streaming via `chat.update`, Block Kit buttons, `/nerve` slash command, and per-thread sessions. `SlackAccessPolicy` (`slack_access.py`) composes user, channel, and DM guardrails; in channels the bot answers only on mention or in a thread it already owns.
- **Web** — Passive channel using gateway WebSocket

Adding a new channel (Discord, WhatsApp, etc.) requires implementing ~5 methods and zero session/routing logic.
Expand Down
161 changes: 160 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ and migration splits a legacy `config.yaml` on the same table:

| Layer | Gets |
|-------|------|
| `config.yaml` | `workspace`, `deployment`, `provider.aws_profile`, `gateway.ssl.*`, `proxy`, `docker`, `telegram.enabled`, `sync.gmail.accounts`, `external_agents`, `mcp_endpoint`, `workflows.runs_dir` |
| `config.yaml` | `workspace`, `deployment`, `provider.aws_profile`, `gateway.ssl.*`, `proxy`, `docker`, `telegram.enabled`, `slack.enabled`, `sync.gmail.accounts`, `external_agents`, `mcp_endpoint`, `workflows.runs_dir` |
| `settings.yaml` | `timezone`, `gateway.host`/`port`, `provider.type`/`aws_region` (incl. the region-scoped Bedrock model IDs), `agent.*`, `memory.*`, `sessions.*`, `sync.*`, the rest of `workflows.*` (the budget caps and cadence), `houseofagents.*`, quiet hours, `telegram.dm_policy`/`stream_mode` |

The test is whether the value would be wrong on another machine: filesystem
Expand Down Expand Up @@ -245,6 +245,7 @@ A reload is always explicit. Two things cause one:
| `external_agents.targets` (including each target's `enabled`), `.sync_interval_minutes`, `.conflict_policy` | ✅ from the next sweep, provided at least one target existed at startup (see the restart table) |
| `sessions.sticky_period_minutes` | ✅ |
| `telegram.dm_policy`, `.stream_mode` | ✅ read per update. Tightening `open` to `pairing` takes effect on the next message; `allowed_users` does not follow it (see the restart table) |
| `slack.*` | ✅ `enabled` starts or stops the channel; same-workspace token changes reconnect and roll back on failure; other changes apply to the next event |
| `workflows.*` and `workflows.review_loop.*` — budget caps, concurrency, the warning fraction, iteration and criteria caps, leg engines/models, the verifier sandbox | ✅ read per use, by loops and runs already in flight as well as new ones. The two `enabled` flags and the two loop cadences are the exceptions; see the restart table |
| `provider.*` and the API keys it selects (`aws_region`, `aws_profile`, `aws_access_key_id`, and the effective Anthropic key) | ✅ for sessions started **after** the reload. Each client's environment is built from the live reference when the session is created, by the same seam as `agent.*` below |
| **`agent.*` and `codex.*`**: backend choice and models (`agent.backend`, `agent.cron_model`, `agent.model`, `codex.model`, `codex.cron_model`), `max_turns`, `agent.effort`/`cron_effort` and `codex.effort_map`, `agent.thinking`, `agent.context_1m*`, `agent.background_agent_permissions`, `agent.agent_teams`, idle timeouts, cache TTL, `codex.sandbox`, `.approval_policy`, `.web_search`, `.extra_config`, `.tool_timeout_sec`, `.bin_path`, `.auth`/`.api_key`/`.api_key_env`, `.pricing`, `.min_version`/`.max_version`, `.ultracode.*` | ✅ for sessions and turns **started after** the reload. The engine and both backends resolve these through one live reference, so a key cannot be hot in one and frozen in the other |
Expand Down Expand Up @@ -607,6 +608,7 @@ ignored when locked, so a secret that lives only there stops being read, and the
feature depending on it breaks on the next restart. Supply each one as `${ENV_VAR}`
referenced from `settings.yaml` before you lock the box. The usual ones:
`auth.jwt_secret`, `auth.password_hash`, `telegram.bot_token`,
`slack.bot_token`/`slack.app_token`,
`anthropic_api_key`/`openai_api_key`, `xmemory.api_key`.

`auth.jwt_secret` is the one to get right. A locked instance that ends up without
Expand Down Expand Up @@ -1070,6 +1072,163 @@ An unauthorized `/start` gets a reply with the sender's numeric ID and
pairing instructions (rate-limited); all other messages from unauthorized
users are ignored.

## Slack

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `slack.enabled` | bool | see below | Enable Slack |
| `slack.bot_token` | string | - | Bot User OAuth Token (`xoxb-…`) |
| `slack.app_token` | string | - | App-Level Token for Socket Mode (`xapp-…`) |
| `slack.allow_users` | list[str] | `[]` | Allowed senders |
| `slack.deny_users` | list[str] | `[]` | Blocked senders |
| `slack.allow_direct_messages` | bool | `false` | Allow DMs; sender rules still apply |
| `slack.allow_channels` | list[str] | `[]` | Allowed shared conversations |
| `slack.deny_channels` | list[str] | `[]` | Blocked shared conversations |
| `slack.stream_mode` | string | `partial` | `partial` (edit one message) or `full` |
| `slack.commands` | list[str] | see below | Enabled `/nerve` subcommands |

Put both tokens in `config.local.yaml`. Reloading can start or stop Slack, and
same-workspace token changes reconnect with rollback on failure. Credentials
for another workspace require a restart. Guardrails, commands, and message
behavior apply to the next event.

Slack runs when `enabled: true`. If the key is omitted, it runs only when both
tokens are present; under lockdown, `enabled: true` is always required. An
explicit `true` also makes `nerve doctor` report missing tokens.

### Setting up the Slack app

Socket Mode means the bot dials out to Slack, so Nerve needs no public URL.

1. Create an app at <https://api.slack.com/apps> from the manifest below.
2. Under **Basic Information → App-Level Tokens**, create a
`connections:write` token for `slack.app_token` (`xapp-…`).
3. Install the app and copy its Bot User OAuth Token to `slack.bot_token`
(`xoxb-…`).
4. Under **App Home → Show Tabs**, enable **Messages Tab** and **Allow users
to send Slash commands and messages**. The manifest cannot set this; without
it DMs are read-only.
5. Configure at least one access grant (see below), then restart Nerve.

```yaml
display_information:
name: Nerve
features:
bot_user:
display_name: Nerve
always_online: true
slash_commands:
- command: /nerve
description: Control the Nerve agent
usage_hint: sessions | new | stop | star | reply | doctor
oauth_config:
scopes:
bot:
- app_mentions:read
- channels:history
- channels:read
- chat:write
- commands
- files:read
- files:write
- groups:history
- groups:read
- im:history
- im:read
- mpim:history
- mpim:read
- reactions:read
- reactions:write
- users:read
- users:read.email
settings:
event_subscriptions:
bot_events:
- app_mention
- message.channels
- message.groups
- message.im
- message.mpim
- reaction_added
interactivity:
is_enabled: true
socket_mode_enabled: true
```

Rules that use only raw IDs do not need `users:read`, `users:read.email`, or
`channels:read`. Names need the corresponding read scope; email rules need
`users:read.email`. If Slack omits identity data needed by a rule, Nerve
refuses the message and logs why.

### Guardrails

Patterns match Slack IDs (`U0123ABC`, `C0456DEF`), handles, emails, or
channel names. Matching is case-insensitive and supports globs. Use raw IDs
to avoid name lookups.

A member edits their own display name and full name, so an `allow_users`
rule never grants on those. Write user grants against the member ID, the
handle, or the email. `deny_users` does match display and full names,
because refusing on more names than a grant may rest on is always safe. A
grant that matches only a self-set name is refused and logged, so the rule
does not fail silently.

```yaml
slack:
allow_users: ["U0123ABC", "alex.soffronow"]
deny_users: ["*-bot"]
allow_direct_messages: true
allow_channels: ["eng-*"]
deny_channels: ["*-social"]
```

- Deny rules always win.
- A non-empty allow list restricts that dimension; an empty one allows
anything not denied.
- Sender and conversation rules are independent. `allow_users` alone permits
those users in any non-denied shared channel; `allow_channels` alone permits
any non-denied user in those channels.
- DMs also require `allow_direct_messages: true`. With no `allow_users`, that
permits any non-denied member who can DM the bot.
- With no `allow_users`, `allow_channels`, or DM grant, Nerve refuses everyone.
Deny rules alone never enable access.
- If a required name lookup fails or omits data, Nerve refuses the message.

### Message behavior

- In an allowed DM, the bot answers every message.
- In a shared channel, it answers mentions and threads where it already has a
session.
- Each shared-channel thread has its own session and all replies stay there;
shared channels never have a channel-wide session.
- Messages another app wrote itself are ignored, so two agents in one channel
cannot answer each other without end. A person posting through an
integration still reaches the agent, because they keep their own user ID.
- `/nerve` responses are ephemeral.

### Commands

`slack.commands` controls slash commands only; chat and notification buttons
are unaffected.

```yaml
slack:
commands: [] # disable /nerve
commands: [reply] # enable only reply
commands: [sessions, new, stop] # enable this exact set
commands: [all] # enable every subcommand
```

Omitting the key enables `new`, `stop`, `star`, `unstar`, and `reply`.
`doctor`, `restart`, and `sessions` are opt-in: the first two expose host
operations, while `sessions` can reach sessions outside Slack and is not
scoped to the caller. Unknown command names are ignored with a warning;
`/nerve help` shows the enabled set.

Slack slash-command payloads have no thread ID. In a shared channel, `new` and
`sessions` therefore refuse, while `stop`, `star`, and `unstar` select among
that channel's active thread sessions. Commands work normally in DMs.

## Quiet Hours

| Key | Type | Default | Description |
Expand Down
9 changes: 8 additions & 1 deletion nerve/channels/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,16 @@ async def send_placeholder(self, target: str, session_id: str) -> str | None:
"""
return None

async def edit_message(self, target: str, message_id: str, text: str) -> None:
async def edit_message(
self, target: str, message_id: str, text: str,
*, throttle: bool = False,
) -> None:
"""Edit a previously sent message (for streaming).

``throttle`` marks an edit the caller can afford to lose, so a
channel may drop it to stay inside a per-conversation rate limit.
A final or recovery edit leaves it False and always goes out.

Only called if channel declares STREAMING capability and
constraints.supports_message_edit is True.
"""
Expand Down
Loading
Loading