Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
.env
.web
.orion
.context/

# Pipeline artifacts
docs/brainstorms/
docs/ideation/
docs/plans/
docs/solutions/

# webui (monorepo frontend)
webui/node_modules/
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Use these when you want deeper customization, integration, or extension details.
| Memory | [`memory.md`](./memory.md) | How nanobot stores, consolidates, and restores memory |
| Python SDK | [`python-sdk.md`](./python-sdk.md) | Use nanobot programmatically from Python |
| Channel plugin guide | [`channel-plugin-guide.md`](./channel-plugin-guide.md) | Build and test custom chat channel plugins |
| Hook plugin guide | [`hook-plugin-guide.md`](./hook-plugin-guide.md) | Build and distribute hook plugins for agent lifecycle events |
| WebSocket channel | [`websocket.md`](./websocket.md) | Real-time WebSocket access and protocol details |
| Custom tools | [`my-tool.md`](./my-tool.md) | Inspect and tune runtime state with the `my` tool |

21 changes: 21 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,27 @@ MCP tools are automatically discovered and registered on startup. The LLM can us



## Hook Plugins

nanobot discovers hook plugins installed via `pip` through the `nanobot.hooks` entry point group. **No plugins are loaded by default** — you must explicitly list the plugins to enable in `hooks.enabled_plugins`. This is a security control to prevent unintended code execution.

When `enabled_plugins` is omitted or set to `null`, all discovered plugins are **skipped**.

```json
{
"hooks": {
"enabled_plugins": ["ratelimit", "audit-log"]
}
}
```

| Option | Default | Description |
|--------|---------|-------------|
| `hooks.enabled_plugins` | `null` (deny all) | List of plugin entry-point names to allow. When `null` or unset, no external hook plugins are loaded. Only listed plugins have their entry points loaded; all others are skipped before module-level code executes. |

See the [hook plugin guide](./hook-plugin-guide.md) for building and packaging hook plugins.


## Security

> [!TIP]
Expand Down
239 changes: 239 additions & 0 deletions docs/hook-plugin-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
# Hook Plugin Guide

Build a custom nanobot hook plugin in three steps: implement, package, install.

Hooks let you observe, transform, or guard agent lifecycle events — without modifying nanobot internals.

## How It Works

nanobot discovers hook plugins via Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/), the same mechanism used by channel plugins. When `nanobot gateway` starts, the HookCenter scans:

1. External packages registered under the `nanobot.hooks` entry point group
2. Plugins listed in `config.hooks.enabled_plugins` allowlist (when configured)

## Quick Start

We'll build a minimal rate-limiting hook plugin that blocks excessive tool calls.

### Project Structure

```text
nanobot-hook-ratelimit/
├── nanobot_hook_ratelimit/
│ ├── __init__.py # re-export RateLimitHandler
│ └── handler.py # handler implementation
└── pyproject.toml
```

### 1. Implement Your Handler

```python
# nanobot_hook_ratelimit/__init__.py
from nanobot_hook_ratelimit.handler import RateLimitHandler

__all__ = ["RateLimitHandler"]
```

```python
# nanobot_hook_ratelimit/handler.py
from nanobot.hooks import BeforeExecuteTools, Deny, Modified



class RateLimitHandler:
"""Block tool execution when a per-session limit is exceeded."""

# Register for tool execution events as a guard handler.
hook_events = [(BeforeExecuteTools, "guard")]

def __init__(self, max_tools_per_turn: int = 10) -> None:
self._max_tools_per_turn = max_tools_per_turn
self._counts: dict[str, int] = {}

async def __call__(self, event: BeforeExecuteTools):
session_id = getattr(event, "session_key", "default")
count = self._counts.get(session_id, 0)

if count >= self._max_tools_per_turn:
return Deny(
f"Rate limit: max {self._max_tools_per_turn} tools per turn "
f"(current: {count})"
)

self._counts[session_id] = count + len(event.tool_calls)
return None


class BlocklistHandler:
"""Abort the agent loop if a blocked tool is called."""

hook_events = [(BeforeExecuteTools, "guard")]

def __init__(self, blocked_tools: list[str] | None = None) -> None:
self._blocked = set(blocked_tools or [])

async def __call__(self, event: BeforeExecuteTools):
for tc in event.tool_calls:
if tc.name in self._blocked:
return Deny(
f"Blocked tool '{tc.name}' — agent loop aborted",
abort=True,
)
return None
```

### 2. Register the Entry Point

```toml
# pyproject.toml
[project]
name = "nanobot-hook-ratelimit"
version = "0.1.0"
dependencies = ["nanobot-ai"]

[project.entry-points."nanobot.hooks"]
ratelimit = "nanobot_hook_ratelimit:RateLimitHandler"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["nanobot_hook_ratelimit"]
```

The key (`ratelimit`) becomes the plugin name shown in logs and used in the `enabled_plugins` allowlist. The value points to your handler class.

### 3. Install & Configure

```bash
pip install -e .
```

Edit `~/.nanobot/config.json` to enable the plugin:

```json
{
"hooks": {
"enabled_plugins": ["ratelimit"]
}
}
```

When `enabled_plugins` is set, **only** listed plugins are loaded. Without this key (or when set to `null`), **no** discovered plugins are loaded — this is a default-deny security policy.

### 4. Verify

```bash
nanobot gateway
```

Check the logs — you should see:
```
Registered hook plugin 'ratelimit' with 1 events
```

## Handler Contract

A hook handler is any callable matching the `HookHandler` protocol:

```python
async def handler(event: EventType) -> HookResult | Modified | Deny | None: ...
```

| Return value | Semantic | Effect |
|-------------|----------|--------|
| `None` | Observe | No action needed; handler ran as a side-effect |
| `Modified(data)` | Transform | Apply the returned data to the event (dict keys mapped to event fields) |
| `Deny(reason)` | Guard (soft) | Block the operation — runner injects the reason as a tool result and continues the loop |
| `Deny(reason, abort=True)` | Guard (hard) | Block the operation — runner terminates the agent loop immediately, `reason` becomes the final content |

### Declaring subscriptions

Your handler class or module must expose a `hook_events` attribute:

```python
hook_events: list[tuple[type, str]] = [
(BeforeIteration, "observe"),
(BeforeExecuteTools, "guard"),
(AfterIteration, "observe"),
]
```

Each tuple is `(event_type, mode)`. Mode must be one of `"guard"`, `"transform"`, or `"observe"`.

## Event Types

v1 exposes six event types covering the agent iteration lifecycle:

| Event | Fields | Mode |
|-------|--------|------|
| `BeforeIteration` | `iteration`, `messages` | guard, observe |
| `OnStream` | `delta`, `iteration` | observe |
| `OnStreamEnd` | `resuming`, `iteration` | observe |
| `BeforeExecuteTools` | `iteration`, `tool_calls`, `response` | guard, observe |
| `AfterIteration` | `iteration`, `final_content`, `stop_reason`, `usage`, `tool_calls`, `tool_events`, `tool_results`, `error` | observe |
| `FinalizeContent` | *(registration marker only)* | transform pipeline |

All event types are importable from `nanobot.hooks`:

```python
from nanobot.hooks import (
BeforeIteration,
AfterIteration,
BeforeExecuteTools,
OnStream,
OnStreamEnd,
FinalizeContent,
Deny,
Modified,
)
```

## Dispatch Order

Within a single event emission, handlers run in this order:

1. **Guards** (internal, then external) — first `Deny` value short-circuits; remaining handlers are skipped.
2. **Transforms** (internal, then external) — chained pipeline; each handler receives data modified by the previous one.
3. **Observes** (internal, then external) — sequential execution with per-handler error isolation.

Internal handlers (built-in framework logic such as streaming and progress) always run before external plugins.

## Security

Hook plugin entry-point loading carries inherent security implications. When the `nanobot gateway` starts, the `HookCenter` loads **only** the hooks listed in `hooks.enabled_plugins`. No plugins are loaded by default — you must explicitly opt in. Any hook plugin has **full access to the agent process** — all conversational data, in-memory state, filesystem access, and network access.

**Important controls:**

- Set `hooks.enabled_plugins` to an explicit allowlist to control which plugins load. Plugins not in this list are skipped before their module-level code executes.
- Audit your plugin dependencies. Any installed hook package can execute arbitrary Python code at `ep.load()` time.
- For high-security deployments, consider running nanobot in a sandboxed environment (`tools.restrictToWorkspace`, `tools.exec.sandbox: bwrap`).

## Naming Convention

| What | Format | Example |
|------|--------|---------|
| PyPI package | `nanobot-hook-{name}` | `nanobot-hook-ratelimit` |
| Entry point key | `{name}` | `ratelimit` |
| Config allowlist | `hooks.enabled_plugins[{name}]` | `ratelimit` |
| Python package | `nanobot_hook_{name}` | `nanobot_hook_ratelimit` |

## Built-in Hook API (AgentHook, backward-compatible)

Legacy `AgentHook` subclasses remain fully supported through a compatibility adapter. Existing hook code (such as the Python SDK usage below) continues to work unchanged:

```python
from nanobot.agent import AgentHook, AgentHookContext


class AuditHook(AgentHook):
async def before_execute_tools(self, context: AgentHookContext) -> None:
for tc in context.tool_calls:
print(f"[audit] {tc.name}")

# Works as before — adapted internally to HookCenter
result = await bot.run("hello", hooks=[AuditHook()])
```

See the [Python SDK guide](./python-sdk.md) for the full SDK hooks API reference.
42 changes: 42 additions & 0 deletions docs/python-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,48 @@ Run the agent once and return a `RunResult`.

## Hooks

There are two ways to write hooks: the new **event-based HookCenter API** and the **legacy AgentHook API**. Both work — the legacy AgentHook is automatically adapted to the HookCenter at runtime.

### Event-based hooks (recommended for new plugins)

The event-based API uses typed event dataclasses. Handlers subscribe to specific event types and return `None` (observe), `Modified(data)` (transform), or `Deny(reason)` (guard):

```python
from nanobot.hooks import BeforeExecuteTools, Deny



class RateLimitHandler:
hook_events = [(BeforeExecuteTools, "guard")]

def __init__(self, max_calls: int = 10):
self._count = 0
self._max_calls = max_calls

async def __call__(self, event: BeforeExecuteTools):
self._count += len(event.tool_calls)
if self._count > self._max_calls:
return Deny(f"Rate limit exceeded ({self._max_calls} max)")
return None
```

Event types importable from `nanobot.hooks`:

| Event | Purpose |
|-------|---------|
| `BeforeIteration(iteration, messages)` | Before each LLM iteration |
| `OnStream(delta, iteration)` | On each streaming token |
| `OnStreamEnd(resuming, iteration)` | When streaming finishes |
| `BeforeExecuteTools(iteration, tool_calls, response)` | Before tool execution |
| `AfterIteration(iteration, final_content, stop_reason, usage, ...)` | After each iteration |
| `FinalizeContent` *(registration marker)* | Content transform pipeline |

Return types: `Deny(reason)`, `Modified(data)`, `None`.

For packaging and distribution (entry_points), see the [hook plugin guide](./hook-plugin-guide.md).

### Legacy AgentHook API (backward-compatible)

Hooks let you observe or customize the agent loop. Subclass `AgentHook` and override the methods you need.

### Hook lifecycle
Expand Down
12 changes: 11 additions & 1 deletion nanobot/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
from nanobot.bus.queue import MessageBus
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults
from nanobot.hooks.adapters import adapt_agent_hook_list
from nanobot.hooks.center import HookCenter
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot
from nanobot.session.manager import Session, SessionManager
Expand Down Expand Up @@ -209,6 +211,7 @@ def __init__(
tools_config: ToolsConfig | None = None,
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
provider_signature: tuple[object, ...] | None = None,
hooks_config: Any | None = None,
):
from nanobot.config.schema import ExecToolConfig, ToolsConfig, WebToolsConfig

Expand Down Expand Up @@ -243,6 +246,8 @@ def __init__(
self._start_time = time.time()
self._last_usage: dict[str, int] = {}
self._extra_hooks: list[AgentHook] = hooks or []
self._hook_center = HookCenter()
self._hook_center.discover(hooks_config)

self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
self.sessions = session_manager or SessionManager(workspace)
Expand All @@ -259,6 +264,7 @@ def __init__(
restrict_to_workspace=restrict_to_workspace,
disabled_skills=disabled_skills,
max_iterations=self.max_iterations,
hook_center=self._hook_center,
)
self._unified_session = unified_session
self._max_messages = max_messages if max_messages > 0 else 120
Expand Down Expand Up @@ -618,13 +624,17 @@ def _to_user_message(pending_msg: InboundMessage) -> dict[str, Any]:

return items

center = self._hook_center
hook_session = center.create_session()
adapt_agent_hook_list([hook], hook_session, center)
result = await self.runner.run(AgentRunSpec(
initial_messages=initial_messages,
tools=self.tools,
model=self.model,
max_iterations=self.max_iterations,
max_tool_result_chars=self.max_tool_result_chars,
hook=hook,
center=center,
session=hook_session,
error_message="Sorry, I encountered an error calling the AI model.",
concurrent_tools=True,
workspace=self.workspace,
Expand Down
Loading