# Mugi Plugin Guide
<!-- PLUGINS_GUIDE_VERSION: 3 -->

## Quick reference (read this first)

- `create_plugin` signature: `create_plugin(slug, name, description=..., plugin_py_content=..., overwrite=...)`.
- `slug` must be lowercase `a-z`, digits, and underscore only (example: `my_plugin`).
- **Dashboard plugins:** `create_dashboard_plugin(slug, name, ...)` writes under `~/.mugi/dashboard_plugins/`; `slug` must normalize to `dash_[a-z0-9_]+` (evolution mode only, like `create_plugin`).
- `read_file_chunk` uses `file_path` (not `filepath`) and 0-based `chunk_index`.
- Hermes XML/JSON tool-call example:

```xml
<tool_call>{"name":"read_file_chunk","arguments":{"file_path":"~/.mugi/PLUGINS.md","chunk_index":0,"num_chunks":1}}</tool_call>
```

- Plugins do not block the host agent mid-stream; they run through registered tools/hooks exposed by `PluginContext`.

This document explains how to create plugins for Mugi. It has two sections: a plain-language overview for anyone curious about what plugins are and how they work, and a detailed technical reference for actually building one.

---

## Part 1: Plain-Language Overview

### What is a plugin?

A plugin is a small add-on that gives Mugi new abilities. Plugins can:

- **Add new tools** the agent can use (for example, a tool that checks server uptime, queries a database, or translates text)
- **Add visual feedback** in the Live View panel (custom progress displays, charts, dashboards)
- **Stream real-time metrics** to the native Swift dashboard (live charts that update continuously)
- **Add HTTP endpoints** so the plugin can serve its own web pages or APIs alongside the app
- **Store settings** that users can configure in the Preferences window
- **React to agent events** like when the agent starts thinking, calls a tool, gets a result, or finishes

### Where do plugins live?

All plugins live in `~/.mugi/plugins/`. Each plugin gets its own folder:

```
~/.mugi/plugins/
  my_cool_plugin/
    plugin.toml    ← describes the plugin (name, author, permissions)
    plugin.py      ← the code that runs when the plugin loads
    helpers.py     ← optional extra files
```

Mugi can also ship first-party bundled plugins from `templates/bundled_plugins/` (for example `yt_dlp`). On startup, bundled plugins are synced into `~/.mugi/plugins/` and tracked by hash/version the same way as other plugins.

**Bundled dashboard tiles:** `templates/bundled_dashboard_plugins/dash_*` sync into `~/.mugi/dashboard_plugins/` via `persistence.ensure_bundled_dashboard_plugins()` (same `.mugi_bundled_plugin` semver rules). Shared JSON builders live in the repo package `dashboard_bundled/` (on `PYTHONPATH` with the backend cwd). Tile payloads are **JSON only**; the WK dashboard shell renders with safe DOM APIs (`textContent`) — do not put raw HTML from the network into tile JSON.

### Dashboard plugins (separate folder)

**Dashboard** tiles (the floating HTML overlay and `/dashboard` shell) use a **separate** directory:

```
~/.mugi/dashboard_plugins/
  dash_my_widget/
    plugin.toml    ← must include kind = "dashboard"
    plugin.py
```

- **Folder names** must match `dash_[a-z0-9_]+` (example: `dash_weather`, not `weather`).
- **Not in the main agent tool list** — dashboard plugins do not merge their tools into the parent agent’s OpenAI tools (they can still expose HTTP routes for the dashboard UI).
- **Hooks** are off by default for dashboard plugins so background chat hooks do not run; set `[plugin] agent_hooks = true` in `plugin.toml` if you need hooks.
- **Manage** under **Settings → Dashboard** (reload, enable/disable, remove, plugin settings). Approval uses the same `app_prefs.json` plugin state as regular plugins (`dash_*` slugs).
- **Tile content** is fetched from your plugin HTTP routes: call `ctx.register_dashboard_tile(...)` **and** `ctx.register_route_handler(...)` so `/dashboard` can load each tile URL (`GET /plugin/<slug><path>`). Without a route handler, the manifest lists the tile with an `error` instead of a silent empty panel.
- **Evolution mode:** use **`create_dashboard_plugin`** (not `create_plugin`) to scaffold a dashboard plugin under `~/.mugi/dashboard_plugins/`.

### How does Mugi keep things safe?

When you drop a new plugin folder into `~/.mugi/plugins/`, Mugi does not just blindly run it. Here's what happens:

1. **Discovery**: On launch, Mugi scans the plugins folder and reads each `plugin.toml`.
2. **Hashing**: Mugi computes a SHA-256 hash of all the plugin's Python files and its manifest. This fingerprint uniquely identifies the exact code.
3. **Approval dialog**: If the plugin is new or its code has changed since the last approval, Mugi shows a dialog with the plugin's name, author, description, version, and what permissions it requests. You choose "Trust & Enable", "Disable", or "View Source in Finder" (to inspect the code first).
4. **Loading**: Only after you approve does Mugi actually run the plugin's code.

If someone modifies a plugin's files behind your back, the hash will change and Mugi will ask you to re-approve before loading it again.

### What can a plugin NOT do?

Plugins run in a dedicated subprocess runtime (one runtime per plugin slug). Core Mugi and plugin code communicate through a versioned IPC protocol. This is a hard-cutover model: tools/hooks/routes run out-of-process only.

Permission enforcement is now modeled at runtime boundary (manifest-derived capability token + core policy gate + runtime checks). If a call is denied, the plugin receives deterministic blocked-hook semantics (`on_tool_call_blocked`).

### Managing plugins

Open **Settings → Plugins** in the app. You'll see a list of all discovered plugins. From there you can enable, disable, remove, reload, or open the plugin folder in Finder.

---

## Part 2: Technical Reference

### Minimal plugin

A plugin needs exactly two files. Here is the smallest possible plugin:

**`~/.mugi/plugins/hello-world/plugin.toml`**:
```toml
[plugin]
name = "Hello World"
version = "1.0.0"
author = "Your Name"
description = "A minimal example plugin that adds a greeting tool."

[permissions]
network = false
shell = false
filesystem = "none"
```

**`~/.mugi/plugins/hello-world/plugin.py`**:
```python
from langchain_core.tools import tool


@tool
def hello_plugin():
    """Say hello from a plugin."""
    return "Hello from the Hello World plugin!"


def register(ctx):
    ctx.register_tools([hello_plugin])
```

That's it. After approval, the agent gains a `hello_plugin` tool it can call.

### The manifest: plugin.toml

Every plugin must have a `plugin.toml` in its root directory. Here is the full schema:

```toml
[plugin]
name = "Plugin Name"          # REQUIRED — unique human-readable name
version = "1.0.0"             # semver recommended
author = "Author Name"        # shown in approval dialog and Preferences
description = "What it does." # shown in approval dialog and Preferences
homepage = "https://..."      # optional link

[permissions]
network = false        # true if the plugin makes network requests
shell = false          # true if the plugin runs shell commands
filesystem = "none"    # "none", "read", or "read_write"

[user_decision]
allowed_kinds = ["browser_cookie_source"]  # optional, required for ctx.request_user_decision(...)
```

**Required fields**: Only `[plugin].name` is strictly required. Everything else has sensible defaults.

**Permissions**: The user sees them before approving. **`network`** and **`shell`** are enforced: if set to `false`, the corresponding modules (e.g. `socket`, `requests`, `subprocess`) cannot be imported when your plugin loads; trying to import them raises an error. **`filesystem`** is for transparency only (not yet enforced). Be honest about what your plugin does so the right permissions are declared and the loader can enforce them.

**Per-tool permission overrides**: When a plugin has some tools that need shell access and others that don't, use `[tools.<tool_name>]` sections with a `permissions` key to override the plugin-level default. Missing keys inherit from `[permissions]`. Example:

```toml
[permissions]
network = true
shell = true           # most tools need shell
filesystem = "read_write"

[tools.get_status]
permissions = { shell = false }   # this tool doesn't need shell
```

This ensures `get_status` works even when shell access is disabled in preferences.

**User decision kinds**: If your plugin needs app-mediated user choices (for example browser cookie source selection), declare allowed kinds under `[user_decision].allowed_kinds` and call `ctx.request_user_decision(...)`. Kinds not declared in the manifest are rejected.

### The entry point: plugin.py

The file must be named `plugin.py` and must define a `register(ctx)` function. The `ctx` argument is a `PluginContext` object — your plugin's interface to the app. Everything your plugin does goes through this object.

```python
def register(ctx):
    # This runs once when the plugin is loaded.
    # Use ctx to register tools, renderers, routes, settings, hooks.
    pass
```

You can import from the standard library, any packages installed in Mugi's environment, or from other `.py` files in your plugin directory — **except** modules that your manifest forbids. If `permissions.network` is false, imports of `socket`, `http`, `urllib`, `requests`, `httpx`, and `aiohttp` are blocked. If `permissions.shell` is false, `subprocess` is blocked. The plugin module is loaded with `importlib` under the name `mugi_plugin_<slug>` where `<slug>` is a lowercased, alphanumeric+underscore version of your plugin name (e.g., "My Plugin" → `mugi_plugin_my_plugin`).

### Plugin slug

Your plugin's "slug" is derived from its name: lowercased, non-alphanumeric characters replaced with underscores, leading/trailing underscores stripped. Examples:

| Name | Slug |
|------|------|
| Hello World | `hello_world` |
| My Cool Plugin | `my_cool_plugin` |
| DNS-Tools v2 | `dns_tools_v2` |

The slug is used for: Live View event type prefixes, HTTP route prefixes, settings storage keys, and internal module naming.

---

### PluginContext API Reference

The `ctx` object passed to `register()` exposes the following methods:

#### `ctx.register_tools(tools)`

Register a list of LangChain `@tool` decorated functions. These become available to the agent alongside the built-in tools.

```python
from langchain_core.tools import tool

@tool
def ping_host(hostname: str) -> str:
    """Ping a hostname and return the result."""
    import subprocess
    result = subprocess.run(
        ["ping", "-c", "1", hostname],
        capture_output=True, text=True, timeout=10,
    )
    return result.stdout if result.returncode == 0 else result.stderr

def register(ctx):
    ctx.register_tools([ping_host])
```

**Important details:**
- Each tool must have `.name`, `.description`, and `.args_schema` attributes (the `@tool` decorator provides these automatically).
- The `@tool` decorator is from `langchain_core.tools`. If you use Pydantic models for complex arguments, that works too.
- Your tools are wrapped by the app with error isolation: if your tool raises an exception, it's caught and returned as an error string rather than crashing the agent loop.
- Tool names should be unique across all plugins and built-in tools. Use a descriptive prefix if needed (e.g., `dns_lookup` rather than just `lookup`).

#### `ctx.log(message, level="INFO")`

Write a message to the app's terminal log, attributed to your plugin.

```python
ctx.log("Plugin initialized successfully")
ctx.log("Something went wrong", level="ERROR")
```

Valid levels: `"INFO"`, `"WARN"`, `"ERROR"`, `"DEBUG"`, `"TOOL"`.

#### `ctx.emit_live_view_event(event)`

Send an event to the Live View panel. The `source` field is automatically set to `plugin:<name>`.

```python
ctx.emit_live_view_event({
    "type": "status",
    "message": "Processing data...",
    "tool_name": "my_tool",
})
```

Standard event types: `"status"`, `"progress"`, `"preview"`, `"error"`, `"done"`. You can also use custom types if you register a renderer (see below). Common event fields:

| Field | Type | Description |
|-------|------|-------------|
| `type` | str | Event type (required) |
| `message` | str | Human-readable status text |
| `tool_name` | str | Which tool produced this event |
| `progress_pct` | int | 0–100 progress percentage |
| `preview_text` | str | Text snippet to display in the preview area |
| `preview_image_base64` | str | Base64-encoded image for the preview area |
| `url` | str | Makes the preview row clickable |

#### `ctx.request_user_decision(kind, *, context=None, timeout_s=300, thread_id=None)`

Request a user decision through the app's confirmation UI. Returns the selected choice string, or `None` on timeout/cancel/error.

```python
choice = ctx.request_user_decision(
    "browser_cookie_source",
    context={
        "title": "Retry with browser cookies?",
        "message": "Choose a browser to retry this download.",
        "url": "https://example.com/video",
    },
    timeout_s=300,
)
```

Important behavior:
- `kind` must be listed in `[user_decision].allowed_kinds` in your manifest.
- The plugin source is attributed automatically as `plugin:<slug>`.
- The host validates choices per kind before the response is accepted.

#### `ctx.emit_display_window(filename, title="Display")`

Open the **display window** to show an image or video prominently. The file must be in `~/Documents/Mugi`. Use this when your plugin generates visualizations (charts, diagrams, network traffic plots, video outputs) that should be shown in a dedicated window.

Supported formats: **Images** (.png, .jpg, .jpeg, .gif, .svg, .webp); **Video** (.mp4, .webm, .mov, .avi, .m4v).

```python
# After saving e.g. a plot to ~/Documents/Mugi/network_chart.png:
ctx.emit_display_window("network_chart.png", title="Network Traffic")
```

The agent can also use the built-in **show_display_window** tool for the same effect.

#### `ctx.open_stream(stream_id=None, stream_type="timeseries", title=None, metadata=None, thread_id=None)`

Open a real-time stream for the native plugin dashboard. Returns the `stream_id` (generated automatically if omitted).

```python
stream_id = ctx.open_stream(
    stream_type="timeseries",
    title="Network Traffic Monitor",
    metadata={"unit": "packets/sec"},
)
```

Use this once before sending updates.

#### `ctx.push_stream_update(stream_id, payload, thread_id=None)`

Send a live data update for an open stream.

```python
ctx.push_stream_update(stream_id, {
    "status": "running",
    "message": "Captured sample",
    "metrics": {
        "tcp_packets": 42,
        "udp_packets": 11,
        "icmp_packets": 1,
    },
})
```

`payload` is plugin-defined JSON. For chart-friendly updates, include a numeric `metrics` dictionary.

#### `ctx.close_stream(stream_id, status="completed", payload=None, thread_id=None)`

Close a stream when work is finished.

```python
ctx.close_stream(stream_id, status="completed", payload={"message": "Capture complete"})
```

#### `ctx.emit_stream_error(stream_id, message, payload=None, thread_id=None)`

Emit an error state for a stream.

```python
ctx.emit_stream_error(stream_id, "Capture failed: permission denied")
```

This marks the stream as failed in the dashboard while preserving previously streamed points.

#### `ctx.register_stream_controls(stream_id, controls, thread_id=None)`

Register interactive dashboard controls for an open stream. Controls are rendered
in the native Plugin Dashboard and can send user changes back to your plugin.

```python
ctx.register_stream_controls(stream_id, [
    {"id": "top_pid_count", "type": "slider", "label": "Top PIDs", "min": 1, "max": 10, "step": 1, "default": 3},
    {"id": "capture_type", "type": "select", "label": "Capture Type", "options": ["general", "dns", "http"], "default": "general"},
    {"id": "paused", "type": "toggle", "label": "Pause Updates", "default": False},
    {"id": "stop_now", "type": "button", "label": "Stop", "action_id": "stop_capture", "style": "destructive"},
])
```

Supported control `type` values:
- `slider` (requires `min`, `max`; optional `step`, `default`)
- `toggle` (optional `default`)
- `select` (requires `options`; optional `default`)
- `button` (optional `action_id`, `style`, `confirm`)

#### `ctx.register_control_handler(callback)`

Register a callback for reverse-channel control events sent from the dashboard.
The callback receives one argument (`event`) or two (`event, ctx`).

```python
def on_control(event, ctx):
    # event includes: plugin_id, stream_id, control_id, control_type, value, action_id
    if event["control_id"] == "top_pid_count":
        ctx.set_setting("top_pid_count", int(event["value"]))
        return {"status": "applied", "message": "Top PID count updated."}
    if event["control_id"] == "stop_now":
        return {"status": "applied", "message": "Stop requested."}
    return {"status": "applied"}

def register(ctx):
    ctx.register_control_handler(on_control)
```

Handler return values:
- `{"status": "applied", "message": "..."}` to acknowledge success.
- `{"status": "rejected", "message": "..."}` to reject the requested change.

#### `ctx.resolve_safe_path(path, allow_write=False)`

Resolve a file path using the same safety rules as built-in tools. Returns `(absolute_path, None)` on success or `(None, error_string)` on failure.

```python
abs_path, err = ctx.resolve_safe_path("data/output.csv", allow_write=True)
if err:
    return f"Path error: {err}"
```

#### `ctx.is_path_allowed(path)`

Check if a path is allowed by the safety module. Returns `True` or `False`.

#### `ctx.register_live_view_renderer(event_type, renderer)`

Register a custom JavaScript renderer for a Live View event type. The event type **must** start with `plugin_<slug>_` (e.g., `plugin_hello_world_status`).

```python
def register(ctx):
    ctx.register_live_view_renderer("plugin_hello_world_chart", {
        "js": """function(event) {
            return '<div style="padding:8px;background:#1a1a2e;border-radius:6px;">'
                + '<strong>' + event.message + '</strong>'
                + (event.preview_text ? '<pre>' + event.preview_text + '</pre>' : '')
                + '</div>';
        }"""
    })
```

The JavaScript function receives the event object and must return an HTML string. It is injected into the Live View page's `window._pluginRenderers` registry. When the Live View receives an event with your custom type, it calls your renderer instead of the default display.

**To emit events that use your custom renderer**, combine it with `ctx.emit_live_view_event`:

```python
ctx.emit_live_view_event({
    "type": "plugin_hello_world_chart",
    "message": "Scan complete",
    "preview_text": "Found 42 results",
})
```

#### `ctx.register_route_handler(handler)`

Register a single route handler for IPC-proxied plugin HTTP requests. `handler` receives a request dict and returns a response dict:

- request: `{method, path, query, headers, body}`
- response: `{status, headers, body}`

```python
def route_handler(req: dict):
    if req.get("path") == "/dashboard":
        return {
            "status": 200,
            "headers": {"content-type": "text/html; charset=utf-8"},
            "body": "<h1>Plugin Dashboard</h1><p>Hello!</p>",
        }
    return {"status": 404, "headers": {"content-type": "text/plain"}, "body": "Not found"}

def register(ctx):
    ctx.register_route_handler(route_handler)
```

After the plugin loads, the dashboard is accessible at `http://localhost:<port>/plugin/hello_world/dashboard`.

`ctx.register_routes(setup_fn)` is deprecated in sandbox mode and should be migrated.

#### `ctx.register_settings(schema)`

Declare settings that appear in Preferences > Plugins when your plugin is selected.

```python
def register(ctx):
    ctx.register_settings({
        "name": "Hello World",
        "fields": [
            {"key": "timeout", "type": "number", "default": 5, "label": "Timeout (seconds)"},
            {"key": "verbose", "type": "bool", "default": False, "label": "Verbose logging"},
            {"key": "api_key", "type": "secret", "default": "", "label": "API Key"},
        ]
    })
```

Field types: `"number"`, `"bool"` or `"boolean"`, `"string"`, `"secret"`.

Settings are listed under **Settings → Plugins** when you expand a plugin (SwiftUI). They are persisted under `plugins.<slug>.settings` in `~/.mugi/app_prefs.json`. For `"secret"` fields, values are stored in macOS Keychain (service `MugiPluginSecret`) and prefs keep only an internal sentinel. The headless API exposes settings on `GET /preferences/plugins` (`settings_schema`, `settings`) and updates via `PUT /preferences/plugins/<slug>/settings` with body `{"settings": {"key": value, ...}}` (validated against the loaded plugin schema).

#### `ctx.get_setting(key, default=None)`

Read a setting value. Returns the stored value or `default` if not set.

```python
timeout = ctx.get_setting("timeout", 5)
```

#### `ctx.set_setting(key, value)`

Write a setting value. Persisted immediately to `~/.mugi/app_prefs.json`.

```python
ctx.set_setting("last_run", "2026-02-19")
```

#### `ctx.register_hook(event_name, callback)`

Register a callback for agent lifecycle events.

```python
def on_tool_call(thread_id, tool_name, args):
    ctx.log(f"Agent is calling {tool_name}")

def on_agent_done(thread_id, response):
    ctx.log(f"Agent finished: {response[:80]}...")

def register(ctx):
    ctx.register_hook("on_tool_call", on_tool_call)
    ctx.register_hook("on_agent_done", on_agent_done)
```

**Available hooks:**

| Hook | Arguments | When it fires |
|------|-----------|---------------|
| `on_agent_start` | `(thread_id, messages)` | After tools are loaded, before the first LLM call |
| `on_tool_call` | `(thread_id, tool_name, parsed_args)` | Before a tool is invoked |
| `on_tool_result` | `(thread_id, tool_name, result)` | After a tool returns |
| `on_tool_call_blocked` | `(thread_id, tool_name, blocked_message)` | After a call is denied by policy (`[Policy blocked]...`) |
| `on_agent_done` | `(thread_id, response_buffer)` | Before the agent's final response is delivered |

Hook callbacks are called synchronously. Exceptions are caught and logged — they will not crash the agent.

Important: `on_tool_call` is advisory and can fire before policy resolves final execution. If you need certainty that work happened, use `on_tool_result` and/or handle `on_tool_call_blocked`.

---

### Complete example: a DNS lookup plugin

This example puts several features together.

**`~/.mugi/plugins/dns-tools/plugin.toml`**:
```toml
[plugin]
name = "DNS Tools"
version = "1.0.0"
author = "Mugi User"
description = "DNS lookup and reverse DNS tools with Live View progress."

[permissions]
network = true
shell = false
filesystem = "none"
```

**`~/.mugi/plugins/dns-tools/plugin.py`**:
```python
import socket
from langchain_core.tools import tool


def _make_tools(ctx):
    @tool
    def dns_lookup(hostname: str) -> str:
        """Look up the IP address(es) for a hostname."""
        ctx.emit_live_view_event({
            "type": "status",
            "message": f"Resolving {hostname}...",
            "tool_name": "dns_lookup",
        })
        try:
            results = socket.getaddrinfo(hostname, None)
            ips = sorted(set(r[4][0] for r in results))
            ctx.emit_live_view_event({
                "type": "status",
                "message": f"Resolved {hostname} → {', '.join(ips)}",
                "tool_name": "dns_lookup",
            })
            return f"{hostname} resolves to: {', '.join(ips)}"
        except socket.gaierror as e:
            return f"DNS lookup failed for {hostname}: {e}"

    @tool
    def reverse_dns(ip_address: str) -> str:
        """Perform a reverse DNS lookup on an IP address."""
        try:
            hostname, _, _ = socket.gethostbyaddr(ip_address)
            return f"{ip_address} → {hostname}"
        except socket.herror:
            return f"No reverse DNS record found for {ip_address}"

    return [dns_lookup, reverse_dns]


def register(ctx):
    ctx.log("DNS Tools loading")
    tools = _make_tools(ctx)
    ctx.register_tools(tools)

    ctx.register_settings({
        "name": "DNS Tools",
        "fields": [
            {"key": "timeout", "type": "number", "default": 5, "label": "Lookup timeout (seconds)"},
        ]
    })
    ctx.log(f"DNS Tools loaded — {len(tools)} tools registered")
```

### Tips and best practices

1. **Name your tools descriptively.** The agent sees tool names and descriptions to decide which to call. A tool named `dns_lookup` with a clear docstring works better than one named `lookup`.

2. **Use closures for ctx access.** Define your `@tool` functions inside a factory that receives `ctx`, so they can call `ctx.log()` and `ctx.emit_live_view_event()` without globals.

3. **Handle errors gracefully.** Your tools are wrapped with error isolation, but it's still better to catch exceptions yourself and return informative error messages.

4. **Keep plugin.toml honest.** Declare the permissions your plugin actually uses. If you set `network = false` or `shell = false`, the loader will block those imports — so set them to `true` if your plugin needs them.

5. **Prefix custom event types.** Live View renderer event types must start with `plugin_<slug>_` to avoid collisions with built-in event types or other plugins.

6. **Don't import app internals.** Use only the `PluginContext` API. Directly importing the legacy app (`legacy.app_pyobjc2`), `agent_loop`, or other core modules is unsupported and may break across versions.

7. **Test your plugin.** You can test by placing the folder in `~/.mugi/plugins/`, clicking Reload Plugins in Preferences > Plugins, and approving/re-approving the plugin when prompted. Check the terminal log for errors.

8. **Additional Python files are fine.** You can split your code across multiple `.py` files in the plugin directory. Import them relatively or by their `mugi_plugin_<slug>` module path. All `.py` files are included in the security hash.

9. **If `find_tools` cannot find your plugin tool:** verify the plugin is loaded (no load error/warning), approved after your latest edit (hash changed), and that tool names use the plugin slug prefix (`slug__action`). Reload Plugins after edits.

### File structure summary

```
~/.mugi/plugins/your-plugin/
├── plugin.toml          # Manifest (required)
├── plugin.py            # Entry point with register(ctx) (required)
├── helpers.py           # Optional additional code
├── data/                # Optional data files (not hashed unless .py)
└── README.md            # Optional documentation
```

### Repository sample plugin

This repo includes a copy-ready real-time streaming example:

- `templates/plugin_examples/realtime_network_monitor/`

It demonstrates a plugin tool that wraps `capture_network_traffic` and emits dashboard stream events via:

- `ctx.open_stream(...)`
- `ctx.push_stream_update(...)`
- `ctx.close_stream(...)`
- `ctx.emit_stream_error(...)`
- `ctx.register_stream_controls(...)`
- `ctx.register_control_handler(...)`

### Background execution for real-time tools

Tools that run for a long time (monitors, captures, watchers) should be marked as **realtime** so the agent loop automatically dispatches them to a background thread. This keeps the main agent responsive while the tool streams data to the dashboard.

**In your plugin manifest** (`plugin.toml`):

```toml
[tools.monitor_network_live]
realtime = true
```

**Or set metadata directly** on your `@tool` function:

```python
@tool
def my_long_running_tool(...) -> str:
    ...

my_long_running_tool.metadata = {"realtime": True}
```

When a tool marked `realtime = true` is invoked, the framework:
1. Launches it in a background thread with its own stop signal
2. Immediately returns a handle ID to the agent so it can continue chatting
3. The agent (or user) can check status with `check_background_task` or stop it with `stop_background_task`
4. Stream events (`ctx.push_stream_update(...)`) continue flowing to the dashboard

The agent can also manually dispatch any tool to background using `run_in_background(tool_name=..., tool_args=...)`, even if it's not marked realtime.

### Troubleshooting

- **Plugin doesn't appear?** Check that the folder contains both `plugin.toml` and `plugin.py`, and that `[plugin].name` is set in the TOML.
- **Plugin shows an error?** Check the terminal log (right pane > Terminal) for `[PLUGIN]` messages.
- **"Module 'X' is not permitted to import" or "Plugin 'Y' is not permitted to import"?** Your manifest has `network = false` or `shell = false`, but your code imports a module that requires that permission. Set the matching permission to `true` in `plugin.toml` (e.g. `network = true` for `socket` or `requests`).
- **Tools don't show up for the agent?** Make sure the plugin is approved and enabled (Settings > Plugins). Also verify the `@tool` decorator is applied and each tool has a docstring.
- **Live View renderer not working?** Verify the event type starts with `plugin_<slug>_` and the JS function returns valid HTML.
- **Plugin was modified but didn't reload?** Modified plugins need re-approval. The app will show the approval dialog on next launch, or you can click "Reload All" in Settings > Plugins.
