Quick start in 5 minutes
Build a minimal cron plugin that checks in every hour and queues a note. No external dependencies, no core imports.
$FUROSHIKI_DATA/plugins/ (default ~/.furoshiki/plugins/).furoshiki_plugin_sdk (always on PYTHONPATH). Use the PluginClient to read data or queue notes.furoshiki plugins reload merges your cron hook into schedules.json. furoshiki plugins list confirms it's active.# 1. Create directories mkdir -p ~/.furoshiki/plugins/my-plugin/scripts # 4. Reload and verify furoshiki plugins reload furoshiki plugins list
--- name: my-plugin version: 1.0.0 description: "Hourly check-in" 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 queues a note.
from furoshiki_plugin_sdk import PluginClient client = PluginClient() # reads env vars automatically client.enqueue( kind="note", summary="Hourly check ran OK", dedupe_key="my-plugin:hourly", ) client.log("info", "my-plugin finished")
dedupe_key field prevents duplicate entries — if a row with the same key already exists in mind_queue, the insert is silently skipped.
Choosing the right hook
Each hook fires at a specific lifecycle event. Pick the one that matches when you want to act.
mind_queue_item NDJSON{"label","content","priority"}{"label","content"}{"conversation_mode","prompt_addendum"}Hook input / output contracts
Arguments are passed by plugin_loader. Your script parses them with argparse and writes the required JSON to stdout.
cron
Runs standalone via run_plugin_scheduled.py. No required arguments, no required stdout. Optionally emit mind_queue_item NDJSON lines:
import json import sys item = { "type": "mind_queue_item", "kind": "note", "summary": "Cron job finished", "dedupe_key": "cron:status:2026-04-10", } print(json.dumps(item)) # loader mediates this into mind_queue
pre_conversation
Args: --mode pre_conversation --state-json <path>
import json, 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": "Background task still running (2h 15m)", "priority": 50, }))
callable
Args: --mode callable --args-json <path> --state-json <path>. Return any JSON the model should receive.
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({"running": True, "uptime_sec": 8100}))
pre_conversation, pre_reflection, callable) must emit exactly one JSON object as the last output line. Invalid JSON causes the hook to be skipped with a bad_output log entry.
Using furoshiki_plugin_sdk
The SDK is always on PYTHONPATH for plugin subprocesses — no pip install required. It is stdlib-only and wraps all Plugin API endpoints.
from furoshiki_plugin_sdk import PluginClient, PluginAPIError client = PluginClient() # reads FUROSHIKI_PLUGIN_API_URL + _API_TOKEN # ── Read (no special permission needed) ────────────────────────────── state = client.get_state() history = client.get_emotion_history(days=3) memories = client.get_recent_memories(limit=5) budget = client.get_budget_summary() # ── Read user_facts (requires read_user_facts: true) ────────────────── facts = client.get_user_facts("sleep schedule", n=5, min_similarity=0.3) # ── LLM call (requires llm: true) ──────────────────────────────────── reply = client.call_llm( "Summarise this: " + my_text, system="Be brief.", max_tokens=128, ) # ── Queue a notification (requires output_mind_queue: true) ────────── client.enqueue( kind="note", summary="Long sync has been running 3 hours", dedupe_key="sync:longrun:2026-04-10", priority="normal", ) # ── Log (always available) ──────────────────────────────────────────── client.log("info", "Plugin finished", context={"ms": 142})
Error handling
try: facts = client.get_user_facts("sleep") except PluginAPIError as e: if e.status == 0: # API server not reachable (Brain not started?) pass elif e.status == 401: # Token expired mid-run; degrade gracefully pass elif e.status == 403: # Permission not declared in PLUGIN.md pass else: raise
memories collection (up to 20).call_llm().user_facts. Returns matching fact texts and similarity scores.PluginAPIError(429) if cap exceeded.mind_queue item. Deduplicated by dedupe_key. Queued items reach the user via voice_dispatcher.log_store under source plugin:<name>. Visible in dashboard logs tab.Plugin-owned storage
Each plugin has a dedicated data directory created automatically at $FUROSHIKI_PLUGIN_DATA_DIR (~/.furoshiki/plugins/<name>/data/). Use it freely for caches, state, or a plugin-owned SQLite database.
import os, sqlite3 data_dir = os.environ['FUROSHIKI_PLUGIN_DATA_DIR'] db_path = os.path.join(data_dir, 'cache.sqlite') conn = sqlite3.connect(db_path) # ✅ plugin's own DB — allowed conn.execute('CREATE TABLE IF NOT EXISTS checks (ts TEXT, result TEXT)') conn.execute('INSERT INTO checks VALUES (?, ?)', ('2026-04-10T09:00Z', 'ok')) conn.commit()
longmemory.sqlite, logs.sqlite, or any path constructed from FUROSHIKI_DATA. The static pre-screen in self_modification_dispatcher rejects generated plugins that contain these patterns, and the isolation prevents runtime access anyway.
Permissions reference
| Flag | Unlocks | Notes |
|---|---|---|
read_user_facts: true | get_user_facts() | Semantic search over Chroma user_facts |
output_mind_queue: true | enqueue() + stdout mediation | Items reach the user via voice_dispatcher |
llm: true | call_llm() | Budget enforced; call tagged with plugin source |
network: true | Outbound HTTP from subprocess | Declared intent; not kernel-enforced |
openrouter_web: true | LLM :online mode | Sets FUROSHIKI_PLUGIN_OPENROUTER_WEB=1 |
All read endpoints and /log are available to any valid plugin token — no permission declaration needed.
Testing your plugin
Method 1 — hook-test-suite (recommended)
The reference hook-test-suite plugin exercises all 7 hook types and reports pass/fail. Install it, then use furoshiki plugins test-suite to validate the entire pipeline.
# Install the reference test suite cp -r examples/plugins/hook-test-suite ~/.furoshiki/plugins/ # Enable it sed -i 's/active: false/active: true/' \ ~/.furoshiki/plugins/hook-test-suite/PLUGIN.md # Reload and run furoshiki plugins reload furoshiki plugins test-suite # Inspect results cat ~/.furoshiki/plugins/hook-test-suite/data/test-runs.jsonl # Disable when done sed -i 's/active: true/active: false/' \ ~/.furoshiki/plugins/hook-test-suite/PLUGIN.md furoshiki plugins reload
Method 2 — manual subprocess test
Issue a token manually, set env vars, and run your hook script directly without waiting for Brain to fire it.
# Make sure Brain is running (so the API is live) furoshiki start # Issue a short-lived token (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('export FUROSHIKI_PLUGIN_API_TOKEN=' + token) EOF # Set env and run the hook export FUROSHIKI_PLUGIN_API_URL=http://127.0.0.1:7432/api/v1/plugin 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
Checking results
# View plugin logs in the dashboard or CLI furoshiki logs plugin:my-plugin # Check mind_queue for queued items python3 -c " import sys; sys.path.insert(0, 'scripts') from db import get_conn conn = get_conn() rows = conn.execute(\"SELECT kind, summary, dedupe_key, status FROM mind_queue ORDER BY queued_at DESC LIMIT 10\").fetchall() for r in rows: print(dict(r)) "
Self-modification path
Furoshiki can draft plugins autonomously via the delegation pipeline. Describe what you want in Telegram or via the inject API and let the system generate a proposal.
self_modification_dispatcher.sqlite3.connect(), longmemory.sqlite, or from db import.~/.furoshiki/proposals/ and a mind_queue notification is queued. You review it at your leisure.furoshiki plugins apply <proposal-file> installs the approved plugin into plugins/ and reloads schedules.Troubleshooting
| Symptom | Check |
|---|---|
Plugin missing from furoshiki plugins list | Is the directory under $FUROSHIKI_DATA/plugins/? Is PLUGIN.md valid YAML with active: true? |
| Cron hook not running | Did you run furoshiki plugins reload after the edit? Check schedules.json for a plugin__<name> entry. |
401 Invalid or expired plugin token | Hook ran longer than its timeout + 30 s grace. Increase timeout in PLUGIN.md. |
403 Plugin permission not declared | Add the required permission flag to permissions: in PLUGIN.md, then furoshiki plugins reload. |
PluginAPIError(0, ...) connection refused | Brain is not running. Start it: furoshiki start. |
Hook output ignored / bad_output in logs | Stdout must be valid JSON for hooks that require it. Check for stray print() before the JSON line. |
ImportError: No module named 'db' | Plugin is trying to import core Furoshiki modules. Replace with SDK calls. scripts/ is intentionally not on PYTHONPATH. |
| Self-mod draft rejected by pre-screen | The generated code contained sqlite3.connect() or from db import. Rephrase the request to not require direct DB access. |