nanobot/docs/extension-authoring.md

307 lines
9.7 KiB
Markdown

# Extension Authoring
nanobot extensions are packages with a strict `nanobot.extension.json`
manifest. The manifest can be inspected without importing optional SDKs or
executing package code. Runtime activation then projects each contribution into
the native nanobot registry that owns that capability.
This guide covers native Python extensions and the compatibility boundary for
Pi and OpenClaw packages. Users installing packages should read
[Extensions](./extensions.md).
## Package Layout
A minimal native package is:
```text
my-extension/
|-- nanobot.extension.json
`-- my_extension/
`-- __init__.py
```
`nanobot.extension.json`:
```json
{
"id": "acme.review",
"name": "Acme Review",
"version": "1.0.0",
"apiVersion": 1,
"runtime": "python",
"entry": "my_extension:register",
"description": "Adds a review tool and command.",
"homepage": "https://example.com/acme-review",
"license": "MIT",
"contributions": [
{
"kind": "tool",
"name": "review_code",
"description": "Review a local change."
},
{
"kind": "command",
"name": "review",
"description": "Start a review from chat."
}
],
"dependencies": [
{
"kind": "python",
"name": "acme-review-core",
"specifier": ">=1,<2",
"optional": false
}
],
"permissions": [
{
"name": "workspace.read",
"reason": "Read files selected for review."
}
]
}
```
Unknown fields are rejected. This is deliberate: a misspelled permission,
dependency, or contribution must not silently change package behavior.
## Manifest Reference
### Top-level fields
| Field | Required | Meaning |
|---|---:|---|
| `id` | yes | Stable lowercase package identity |
| `name` | yes | Human-readable name |
| `version` | yes | Installed package version |
| `apiVersion` | no | Manifest API, currently `1` |
| `runtime` | yes | `python`, `pi`, `openclaw`, or `declarative` |
| `entry` | runtime-dependent | One activation entry |
| `entries` | runtime-dependent | Multiple Pi/OpenClaw entries; takes precedence over `entry` |
| `contributions` | no | Capabilities owned by the package |
| `dependencies` | no | Activation prerequisites |
| `permissions` | no | Privileged host capabilities requested from the user |
| `description` | no | Catalog summary |
| `homepage` | no | Project or documentation URL |
| `license` | no | SPDX-style license label |
Entries must be relative to the package root and cannot contain `..`.
### Contributions
Supported `kind` values are:
```text
tool
skill
channel
llm_provider
transcription_provider
image_generation_provider
web_search_provider
mcp_server
hook
command
webui
```
Each contribution has a stable `name`, optional runtime `target`, and optional
`description`. Two active extensions cannot own the same contribution name.
Disable one owner before activating the other; v1 deliberately does not allow
extensions to replace core or third-party registrations.
The manifest declares ownership. It does not create an implementation by
itself. A runtime must register the corresponding native capability.
### Dependencies
| Kind | `name` identifies | Version behavior |
|---|---|---|
| `python` | Installed Python distribution | PEP 440 specifier |
| `npm` | Package under the extension's `node_modules` | npm installation constraint |
| `executable` | Command on `PATH` | No version probe |
| `environment` | Environment variable | Must be non-empty |
| `extension` | Another extension ID | Active extension version |
Set `optional: true` when the extension can activate without the dependency.
Do not put API keys in the manifest.
### Permissions
Permission names are lowercase namespaced identifiers such as
`workspace.read`, `workspace.write`, or `network.http`. Include a concrete
reason the user can evaluate. Activation requires every requested permission to
be granted.
Permissions are review and activation gates; they do not constrain direct
Python or Node process access and are not an OS sandbox. Keep the request set
minimal and use native host operations when one exists.
## Native Python Runtime
The Python entry is `module[:attribute]`; the default attribute is `register`.
The function is synchronous and must return `None`:
```python
from nanobot.agent.tools.base import Tool
class ReviewTool(Tool):
@property
def name(self) -> str:
return "review_code"
@property
def description(self) -> str:
return "Review a local code change."
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"path": {"type": "string"},
},
"required": ["path"],
}
async def execute(self, path: str) -> str:
return f"Review requested for {path}"
def register(api) -> None:
api.register_tool(ReviewTool())
```
The v1 Python API exposes:
- `register_tool(tool)`
- `register_command(command, handler, prefix=False)`
- `register_hook_factory(factory)`
These methods use existing nanobot registries. Do not import or patch
`AgentLoop`, reach into WebSocket internals, or create a second tool registry.
If a new contribution kind needs execution support, add an adapter at the
native subsystem boundary and keep the manifest API independent from that
implementation.
## Publish and Discover
For npm discovery, include the exact `nanobot-extension` keyword:
```json
{
"keywords": [
"nanobot-extension"
]
}
```
Compatibility packages use their upstream metadata and keyword:
- Pi: `pi-package` plus `pi.extensions`
- OpenClaw: `openclaw-plugin` plus `openclaw.extensions` or
`openclaw.runtimeExtensions`
The adapter adds the `runtime.node` permission to both generated manifests.
Users must explicitly grant it before any third-party JavaScript or TypeScript
entry runs.
Native Python packages can be installed from a local directory or Git source.
The market is an index; installation always passes through local validation,
dependency checks, permission review, trust, and activation.
## Compatibility Matrix
The matrix describes the current adapter, not an intention to support every
upstream API.
| Upstream capability | Pi | OpenClaw | nanobot behavior |
|---|---|---|---|
| Tool registration and calls | Executable | Executable | Projected as native `Tool`; implementation stays in Node sidecar |
| Slash commands | Executable | Executable | Registered in native command router |
| Supported lifecycle events | Observation-only | Observation-only | Receives serialized run/tool events; cannot mutate native context |
| Provider registration | Metadata only | Metadata only | Visible in catalog and diagnostics; not an executable provider |
| Transcription/image/web-search provider contracts | N/A | Metadata only | Visible but not projected into native provider registries |
| Skills declared in plugin metadata | N/A | Metadata only | Catalog ownership only; runtime skill installation is not synthesized |
| Channels | N/A | Metadata only | No OpenClaw channel host emulation |
| Session tree and custom entries | Unsupported | Unsupported | No compatible host surface |
| Terminal UI, widgets, renderers, shortcuts | Unsupported | Unsupported | WebUI and CLI have different rendering contracts |
| Model selection and thinking control | Unsupported | Unsupported | Remains owned by nanobot model presets and request policy |
| Arbitrary upstream host services | Unsupported | Unsupported | Reported as diagnostics rather than silently emulated |
### Pi package shape
```json
{
"name": "@acme/pi-review",
"version": "1.0.0",
"keywords": ["pi-package"],
"pi": {
"extensions": ["./index.ts"]
}
}
```
The sidecar supports `registerTool`, `registerCommand`, and selected `on(...)`
lifecycle handlers. TypeScript uses Node's native type stripping when
available, with `jiti` as a fallback installed with the package runtime.
Required `peerDependencies` are installed into that runtime as well; peers
marked optional in `peerDependenciesMeta` remain optional.
The generated manifest requests `runtime.node`.
### OpenClaw package shape
```json
{
"name": "@acme/openclaw-review",
"version": "1.0.0",
"keywords": ["openclaw-plugin"],
"openclaw": {
"runtimeExtensions": ["./dist/index.js"]
}
}
```
If present, `openclaw.plugin.json` supplies catalog identity, contribution
contracts, command aliases, and compatibility diagnostics. The OpenClaw
`register` function must complete synchronously during load.
The generated manifest requests `runtime.node`.
## Test an Extension
Use an isolated config and workspace while developing:
```bash
nanobot extensions install ./my-extension --kind local
nanobot extensions inspect acme.review
nanobot extensions permissions acme.review workspace.read
nanobot extensions trust acme.review
nanobot extensions enable acme.review
nanobot agent -m "Use review_code on README.md"
```
Also test:
- install while untrusted does not execute code;
- package changes after installation revoke effective trust;
- missing hard dependencies leave the package inactive;
- extension dependencies activate before their dependents and reject cycles;
- denied or missing permissions prevent activation;
- duplicate contribution names become diagnostics;
- disable, untrust, reload, and uninstall remove runtime registrations;
- one broken package does not block unrelated packages.
For nanobot itself, extension tests live under `tests/extensions/`. Keep
compatibility fixtures small and assert diagnostics for unsupported APIs.
## Design Rules
1. Extend the control plane, not the core loop.
2. Keep one native owner for each capability.
3. Make discovery metadata-only.
4. Separate install, permission grant, trust, and enable.
5. Report partial compatibility honestly.
6. Keep market metadata independent from runtime execution.
7. Roll back all registrations owned by a failed or unloaded extension.