Debugging plugins
The audit log pattern
Every non-trivial plugin should write a JSONL audit log under $FLOWLY_HOME/<plugin-name>/log.jsonl. This is the single most useful debugging tool you have — when a plugin runs in a gateway process, you can't attach a debugger, but you can always tail the log file.
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def _log(record: dict[str, Any]) -> None:
"""Append a JSON line to the audit log. Best-effort, never raises."""
try:
log_dir = _get_state_dir() # $FLOWLY_HOME/<plugin>/
log_dir.mkdir(parents=True, exist_ok=True)
record["ts"] = datetime.now(timezone.utc).isoformat()
with open(log_dir / "log.jsonl", "a") as f:
f.write(json.dumps(record) + "\n")
except OSError:
pass # never let logging break the agentEvents worth logging
hook_fired— every time your hook is invoked, with the tool name and the candidate inputs. Critical for debugging "is the hook even running?".committed/tracked/skipped— successful operations.commit_failed/stage_failed— failure modes, witherrorstring.outside_repo/not_allowed— silent skips that would otherwise be invisible.
info level — so inspecting the log file reveals which gate the input hit.Common symptoms
Plugin not loaded
Symptom: Slash command returns "unknown command", tool isn't in flowly plugins list, hook never fires.
Diagnostic steps:
flowly plugins list— does your plugin appear? If status iserror, the manifest parsed butregister(ctx)raised. Check the gateway log for the traceback.flowly plugins listshowing nothing? The manifest is malformed. Trycat ~/.flowly/plugins/<name>/plugin.yamland verify YAML is valid.flowly plugins listshowsnot in plugins.enabled? Runflowly plugins enable <name>.- Did you restart the gateway after enabling? Plugin discovery only runs at startup.
flowly service restartor quit and reopen the desktop app.
Hook fires but nothing happens
Symptom: Plugin is loaded, the agent does something that should trigger your hook, but no commit / no track / no audit event.
Add a fired event at the top of your hook:
def on_post_tool_call(ctx):
_log({
"event": "hook_fired",
"tool": ctx.tool_name,
"params_keys": list((ctx.params or {}).keys()),
"success": ctx.success,
})
# ... rest of your logicNow tail -f ~/.flowly/<plugin>/log.jsonl and trigger an action. Three possible outcomes:
- No
hook_firedevent — your hook isn't actually registered, or the runtime never calls it for this tool/event combination. Checkregister(ctx)wiring. hook_firedappears but no follow-up — a downstream filter is rejecting the input. Add_logcalls at every early-return so you see which gate caught it.hook_fired+ error event — an exception inside your handler. The error string in the log tells you what.
/tmp is a symlink to /private/tmp. The auto-commit plugin's _find_git_root returned an unresolved path while another step called path.resolve(), so relative_to raised ValueError silently. We caught it by adding an outside_repo log event at every early-return point. Lesson: never except: return without logging.Tool not in agent's tool list
Symptom: You registered my_tool, the plugin shows enabled in flowly plugins list, but when the agent says "I don't have access to that tool" or never calls it.
Check:
- Is the tool name unique? If two plugins (or a plugin and a built-in) register the same name, last writer wins. Run
flowly plugins listwith verbose output if available, or grep gateway logs forregistered tool. - Is the JSON Schema valid? An invalid schema may make the tool unusable by the model. Try a minimal schema first: python
schema = {"parameters": {"type": "object", "properties": {}}} - Is
check_fnalways returning False? It runs at dispatch time. Log inside it to verify.
Slash command says "unknown"
Symptom: Typing /your-cmd in the desktop UI returns "Unknown command".
Likely cause: The desktop UI maintains its own slash whitelist for autocomplete and rejects unknown ones at the input layer. Workaround: prefix with a single quote ('/your-cmd args) — the desktop sees this as a regular message and ships it to the gateway, where the plugin slash dispatcher picks it up.
From other channels (Telegram, Web), this isn't a problem.
Tool runs but action fails silently
Symptom: Hook fires, no error in the log, but the promised action (commit, file write, API call) didn't happen.
Diagnostic:
- For shell-out plugins (git, ffmpeg, etc.), capture both
stdoutandstderrand log them on non-zero exit.subprocess.run(...)withcapture_output=True. - For API-calling plugins, log status code and response body on non-200.
- File operations: check for path-resolution surprises (symlinks, relative paths, expansion of
~).
Agent has my tool but never calls it
Symptom: Plugin loads, slash commands work, the tool shows up in flowly plugins list, but when the user asks something the tool would handle, the agent calls memory_search or knowledge_graph instead — your tool is just ignored.
Root cause: tool descriptions are sent to the model in the API tools parameter, not in the system prompt. The model picks tools based on the description alone. If your description is generic — "Search local data" — the model can't tell when it applies. Worse, your plugin's domain (e.g. legal cases) is invisible to the model unless something tells it.
Two fixes:
- Sharper tool descriptions — include trigger keywords and a one-line "use this when…" clause.python
# Generic — model can't tell when to use it description="Search the database" # Specific — model knows the domain and the trigger description="Find a legal case by client name. Use this FIRST when " "the user mentions a person's name in a legal context " "(dava, müvekkil, dosya) — before memory_search." - Domain context via
pre_llm_call— register a hook that runs before each LLM call and returns a string describing the user's domain. The string is wrapped in<plugin_context>tags and prepended to the user message. The model sees it and reaches for your tools.pythondef _inject_context(ctx) -> str | None: return ( "User is a Turkish family-law attorney with active cases. " "When they mention a client name (Mehmet, Ayşe, Demir), " "use case_search BEFORE memory_search." ) def register(ctx): ctx.register_hook("pre_llm_call", _inject_context)
case-tracker plugin had exactly this problem: 6 case_* tools registered, but the agent kept falling back to memory_search. After adding a 30-line pre_llm_call hook listing the available tools and trigger words, the agent now reliably picks case_search → case_get for any client-name query. Same tools, same model, same prompt — just better orientation.Reading gateway logs
When you start the gateway from a terminal, it streams logs to stdout. Useful events:
# Plugin discovery — at startup
loaded plugin <name> (tools=N hooks=N commands=N)
plugin discovery complete: N found, M enabled
# Tool dispatch — every agent tool call
Executing tool: <tool_name>(...)
Tool success: <tool_name> result=...
Tool failed: <tool_name> result=Error...
# Session lifecycle
Processing message from <channel>:<chat_id>If your plugin's loaded plugin ... line is missing, the plugin failed to load and Flowly logged the traceback above it.
flowly.plugins namespace at INFO level by default. If you don't see your discovery line, this is probably why. Set LOGURU_LEVEL=DEBUG before starting the gateway to bypass.Hard reset checklist
When everything is broken and you want a clean state:
# 1. Stop the gateway
flowly service stop # or close the desktop app
# 2. Disable, remove, re-install the plugin
flowly plugins disable <name>
flowly plugins remove <name> --yes
flowly plugins install <source>
flowly plugins enable <name>
# 3. Wipe the plugin's state directory
rm -rf ~/.flowly/<plugin-name>/
# 4. Start the gateway with debug logging
LOGURU_LEVEL=DEBUG flowly gateway
# 5. Trigger the action and tail the log
tail -f ~/.flowly/<plugin-name>/log.jsonl