# Furoshiki Plugin Author's Guide

**Version:** 1.0 · **Audience:** Plugin developers, self-modification authors, operators extending Furoshiki.

This guide walks you through building a Furoshiki plugin from scratch: picking the right
hook, using the Plugin SDK, storing plugin-owned data, and testing end-to-end.

---

## Your plugins vs. `examples/plugins/` in git

| Location | Use |
|----------|-----|
| **`$FUROSHIKI_DATA/plugins/<name>/`** (usually `~/.furoshiki/plugins/`) | **All installed plugins** — this is the real runtime. Personal or private integrations (API keys, local binaries, your workflows) belong **only** here. Install via copy, **`furoshiki plugins apply`**, or **Dashboard → Plugins → Install from zip**. |
| **`furoshiki/examples/plugins/`** (in the **source** repo) | **Curated reference templates** shipped for documentation, copying, and tests (e.g. `hook-test-suite`). **Do not** treat this as a place to check in one-off or personal plugins unless you intend a **shared, maintainer-reviewed** example for every clone. |

**Dashboard workflow:** open **Plugins**, copy **PLUGIN-SPEC.md** for your coding agent, zip the finished plugin folder, upload, configure **`settings_schema`** if present, then **enable**.

**Rule of thumb:** if only you (or one machine) should use it, keep it out of the repo and under `$FUROSHIKI_DATA/plugins/` only. See **`examples/plugins/README.md`** in the same tree for the same policy in one paragraph.

---

## Quick start (5 minutes)

```bash
# 1. Create your plugin directory
mkdir -p ~/.furoshiki/plugins/my-plugin/scripts

# 2. Write PLUGIN.md (manifest)
cat > ~/.furoshiki/plugins/my-plugin/PLUGIN.md << 'EOF'
---
name: my-plugin
version: 1.0.0
description: "Example plugin"
author: user
active: true
hooks:
  - type: cron
    schedule: "0 * * * *"    # every hour, UTC
    entry: scripts/run.py
    timeout: 30
permissions:
  output_mind_queue: true
---
# My Plugin
Runs every hour and logs a note.
EOF

# 3. Write the hook script
cat > ~/.furoshiki/plugins/my-plugin/scripts/run.py << 'EOF'
#!/usr/bin/env python3
from furoshiki_plugin_sdk import PluginClient
client = PluginClient()
client.enqueue(kind="note", summary="Hourly check ran", dedupe_key="my-plugin:hourly")
client.log("info", "my-plugin ran OK")
EOF

# 4. Reload plugins (merges cron hook into Brain scheduler)
furoshiki plugins reload

# 5. Verify
furoshiki plugins list
```

---

## Choosing a hook type

| You want to… | Use hook type |
|---|---|
| Run on a schedule (e.g. hourly, daily) | `cron` |
| Add context to every conversation | `pre_conversation` (called on-demand when a message arrives) |
| React after a conversation ends | `post_conversation` |
| Add context before Furoshiki reflects | `pre_reflection` |
| React after a reflection completes | `post_reflection` |
| Give the LLM a tool it can call on demand | `callable` |
| Influence conversation-mode classification | `post_inbound_signals` |
| Suppress profile-gap questions | `soul_engine` (target: `profile_gap`) |

**Tip:** `cron` hooks are the simplest — they run standalone with no required stdout
format. `callable` hooks are the most interactive — the LLM decides when to invoke them.

**Self-modification (agent-drafted plugins):** For copy-paste **Telegram** and **CLI** lines to start a `self_modification` job per hook type, see **Self-modification: hook types and test examples** in **`docs/DELEGATION-AND-SKILLS.md`**.

---

## Hook contracts (input → output)

### `cron`

Run by the Brain scheduler via `run_plugin_scheduled.py`. No stdin, no required stdout.

```python
# Optional: emit mind_queue_item via stdout (plugin_loader mediates it)
import json
print(json.dumps({
    "type": "mind_queue_item",
    "kind": "note",
    "summary": "Check completed",
    "dedupe_key": "my-plugin:check:2026-04-10",
}))
```

Or use the SDK:
```python
from furoshiki_plugin_sdk import PluginClient
PluginClient().enqueue(kind="note", summary="Check completed", dedupe_key="my-plugin:check:2026-04-10")
```

### `pre_conversation`

Called **on-demand** when a conversation starts (inside `telegram_listener.build_system_prompt()`).
Not called during periodic context refresh — only when someone is actually talking.

Args: `--mode pre_conversation --state-json <path>`

stdout: JSON `{"label": "...", "content": "...", "priority": 50}`

```python
import json, sys, argparse
p = argparse.ArgumentParser()
p.add_argument('--mode'); p.add_argument('--state-json', dest='state_json')
args = p.parse_args()
print(json.dumps({"label": "my_context", "content": "Some extra context...", "priority": 50}))
```

### `post_conversation`

Args: `--mode post_conversation --state-json <path> --session-json <path>`

stdout: ignored. Use the Plugin API to log or queue notifications.

### `pre_reflection`

Args: `--mode pre_reflection --reflection-type <type> --state-json <path> --prior-context-json <path>`

stdout: JSON `{"label": "...", "content": "..."}`

### `post_reflection`

Args: `--mode post_reflection --reflection-type <type> --state-json <path> --reflection-output-json <path>`

stdout: ignored.

### `callable`

Args: `--mode callable --args-json <path> --state-json <path>`

stdout: any JSON object (returned to the LLM as the tool result).

```python
import json, argparse
p = argparse.ArgumentParser()
p.add_argument('--mode'); p.add_argument('--args-json', dest='args_json'); p.add_argument('--state-json', dest='state_json')
args = p.parse_args()
with open(args.args_json) as f:
    call_args = json.load(f)
# ... do work ...
print(json.dumps({"result": "done"}))
```

### `post_inbound_signals`

Args: `--mode post_inbound_signals --payload-json <path> --state-json <path>`

stdout: JSON `{"conversation_mode": "chat" | null, "prompt_addendum": "..."}`

Return `null` for `conversation_mode` to leave it unchanged.

---

## Using the Plugin SDK

`furoshiki_plugin_sdk` is always available on `PYTHONPATH` for plugin subprocesses.
It is stdlib-only (no pip install required):

```python
from furoshiki_plugin_sdk import PluginClient, PluginAPIError

client = PluginClient()   # reads env vars automatically

# Read
state   = client.get_state()
history = client.get_emotion_history(days=7)
facts   = client.get_user_facts("sleep quality")
budget  = client.get_budget_summary()
memories = client.get_recent_memories(limit=3)

# LLM (requires llm: true in PLUGIN.md)
text = client.call_llm(
    "Summarise the last 3 days of emotion history",
    system="Be brief.",
    max_tokens=200,
)

# Write
client.enqueue(
    kind="note",
    summary="Something happened",
    dedupe_key="my-plugin:event:2026-04-10",
    priority="normal",
)
client.log("info", "Plugin finished", context={"ms": 42})
```

### Error handling

```python
from furoshiki_plugin_sdk import PluginClient, PluginAPIError

client = PluginClient()
try:
    facts = client.get_user_facts("sleep")
except PluginAPIError as e:
    if e.status == 401:
        pass  # token expired; degrade gracefully
    elif e.status == 403:
        pass  # permission not declared
    else:
        raise
```

---

## Plugin-owned storage

Plugins have a dedicated data directory at `$FUROSHIKI_PLUGIN_DATA_DIR`
(created automatically). Use it for state, caches, or a plugin-owned SQLite database:

```python
import os, sqlite3

data_dir = os.environ['FUROSHIKI_PLUGIN_DATA_DIR']
db_path  = os.path.join(data_dir, 'my_plugin.sqlite')

conn = sqlite3.connect(db_path)
conn.execute('CREATE TABLE IF NOT EXISTS checks (ts TEXT, result TEXT)')
conn.execute('INSERT INTO checks VALUES (?, ?)', ('2026-04-10T09:00:00Z', 'ok'))
conn.commit()
```

**Rules:**
- ✅ `sqlite3.connect(os.path.join(data_dir, 'anything.sqlite'))` — allowed
- ❌ `sqlite3.connect('longmemory.sqlite')` — blocked by static pre-screen
- ❌ Constructing the `FUROSHIKI_DATA` path to open core databases — blocked

---

## PLUGIN.md permissions reference

Declare only what your plugin needs:

```yaml
permissions:
  read_user_facts: true     # GET /api/v1/plugin/user-facts
  read_memories: true      # GET /api/v1/plugin/memories-search
  output_mind_queue: true   # POST /api/v1/plugin/mind-queue (or mediated stdout)
  llm: true                 # POST /api/v1/plugin/llm (budget enforced)
  network: false            # outbound network from subprocess
  openrouter_web: false     # LLM endpoint may use OpenRouter :online
```

Most read endpoints (`/state`, `/emotion-history`, `/recent-memories`, `/budget-summary`)
and the `/log` endpoint are available to any valid plugin token. **Semantic search**
endpoints **`/user-facts`** and **`/memories-search`** require the matching
`read_user_facts` / `read_memories` flags in `PLUGIN.md` or the repair API returns **403**.

---

## Testing your plugin

### Method 1: hook-test-suite reference

Install the reference test plugin and use `furoshiki plugins test-suite`:

```bash
cp -r examples/plugins/hook-test-suite ~/.furoshiki/plugins/
sed -i 's/active: false/active: true/' ~/.furoshiki/plugins/hook-test-suite/PLUGIN.md
furoshiki plugins reload
furoshiki plugins test-suite
cat ~/.furoshiki/plugins/hook-test-suite/data/test-runs.jsonl
```

### Method 2: manual subprocess test

```bash
# Start the Brain so the API is available
furoshiki start

# Issue a token manually (Python)
python3 - << 'EOF'
import sys; sys.path.insert(0, 'scripts')
from api_v1_routes import issue_plugin_token
token = issue_plugin_token('my-plugin', ['output_mind_queue'], timeout_sec=60)
print('Token:', token)
EOF

# Set env and run your hook manually
export FUROSHIKI_PLUGIN_API_URL=http://127.0.0.1:7432/api/v1/plugin
export FUROSHIKI_PLUGIN_API_TOKEN=<token from above>
export FUROSHIKI_PLUGIN_DIR=~/.furoshiki/plugins/my-plugin
export FUROSHIKI_PLUGIN_DATA_DIR=~/.furoshiki/plugins/my-plugin/data
python3 ~/.furoshiki/plugins/my-plugin/scripts/run.py
```

### Method 3: enable and trigger via Brain

```bash
furoshiki plugins reload
# For cron hooks: wait for the scheduled time, or modify the schedule to run soon
# For callable hooks: send a Telegram message that would invoke the tool
# For pre/post conversation: start any conversation
```

### Checking results

```bash
# Lines from PluginClient.log() / POST /api/v1/plugin/log (source plugin:<slug>)
furoshiki logs plugins
# Loader / hook lifecycle (plugin_pipeline) → log_store source "extensions"
furoshiki logs extensions

# View mind_queue items
python3 -c "
import sys; sys.path.insert(0, 'scripts')
from db import get_conn
conn = get_conn()
rows = conn.execute(\"SELECT * FROM mind_queue WHERE kind='plugin' ORDER BY queued_at DESC LIMIT 10\").fetchall()
for r in rows: print(dict(r))
"
```

---

## Self-modification path

Furoshiki can generate plugins for you via delegation:

1. Describe what you want: "Create a plugin that checks weather every morning and queues a note"
2. The delegation dispatcher creates a self-modification job
3. `self_modification_dispatcher` runs a creation pass + static pre-screen + LLM review
4. If approved, a proposal is written to `~/.furoshiki/proposals/`
5. Review the proposal, then: `furoshiki plugins apply <proposal-file>`

**Static pre-screen rules** (auto-rejected before LLM review):
- `sqlite3.connect()` calls
- References to `longmemory.sqlite` or `logs.sqlite`
- `FUROSHIKI_DATA` environment variable access (bypasses isolation)
- `from db import` or `import db`

These checks ensure LLM-generated code cannot reach Furoshiki's core databases.

---

## What the LLM sees when your callable fails

When a `callable` hook fails (timeout, non-zero exit, bad JSON, crash), Furoshiki
returns a **structured failure report** to the LLM instead of a bare error string.
The model sees a `_tool_failure` object inside the tool result:

```json
{
  "error": "plugin produced no stdout",
  "label": "my_skill",
  "content": "",
  "urgency": "low",
  "_tool_failure": {
    "tool": "my_skill",
    "reason": "plugin produced no stdout",
    "reason_code": "timeout",
    "severity": "transient",
    "recoverable": true,
    "dependency_of": ["compose_reply"],
    "fallback": "Retry this call after the timeout window.",
    "detail": {
      "exit_code": -1,
      "reason": "timeout",
      "timeout_sec": 30
    }
  }
}
```

**Fields:**

| Field | Meaning |
|-------|---------|
| `tool` | Which tool failed |
| `reason` | Human-readable — what happened |
| `reason_code` | Machine-readable: `timeout`, `process_exit`, `bad_output`, `execution_error`, `validation_error`, `unknown_skill`, `blocked`, `internal_error`, `no_output`, `empty_name` |
| `severity` | `transient` (timeout, rate-limit — retry may help) or `fatal` (bad config, unknown tool — do not retry) |
| `recoverable` | `true` if retrying the same call might succeed |
| `dependency_of` | Names of other tools the model called in the same round that likely depend on this output |
| `fallback` | Prose guidance — what the model should do instead |
| `detail` | Raw context: `exit_code`, `stderr`, `exception`, `timeout_sec` |

**For plugin authors:** your plugin should produce **valid JSON** on stdout and
exit with code 0 on success. When your plugin exits non-zero or produces malformed
output, Furoshiki wraps the failure with the structured schema above. Subprocess
details (exit code, stderr) are included in `detail` when available.

**For self-modification agents:** this schema gives you a clear signal about what
failed and what to do next — use `reason_code` to branch (retry on `timeout`,
report on `fatal`, etc.) rather than parsing error strings.

## Troubleshooting

| Problem | Check |
|---------|-------|
| Plugin not appearing in `furoshiki plugins list` | Is it under `$FUROSHIKI_DATA/plugins/`? Is `PLUGIN.md` valid YAML? |
| Cron hook not running | Did you run `furoshiki plugins reload`? Check `schedules.json` for `plugin__my-plugin` entry. |
| `401 Invalid or expired plugin token` | Token expired mid-run (hook exceeded timeout). Increase `timeout` in PLUGIN.md. |
| `403 Plugin permission not declared` | Add the required permission to `permissions:` in PLUGIN.md, then restart. |
| `PluginAPIError(0, ...)` | API server not running. Start Brain: `furoshiki start`. |
| SDK `ImportError` | `furoshiki_plugin_sdk` not found — `PYTHONPATH` not set. Use `run_plugin_scheduled.py` or `plugin_loader._invoke_plugin`. |
| Hook output ignored | Check log for `bad_output` — stdout must be valid JSON for hooks that require it. |

---

## Reference

- **Plugin API endpoints:** `docs/PLUGIN-API.md`
- **Full plugin spec:** `docs/PLUGIN-SPEC.md`
- **SDK source:** `scripts/furoshiki_plugin_sdk.py`
- **Reference plugins:** `examples/plugins/` — **curated templates only** (not for personal-only plugins; see [Your plugins vs. `examples/plugins/` in git](#your-plugins-vs-examplesplugins-in-git)). **`hook-test-suite`** is canonical; `profile-gap-policy-sample` and `post-inbound-policy-sample` show policy hooks; **`memory-deep-recall`** is a callable sample using **`read_memories`**, **`read_user_facts`**, optional LLM synthesis, and dashboard **`settings_schema`**
- **CLI commands:** `furoshiki extensions plugins help`
