Plugin settings
Principle: no env-var-only config
Environment variables are convenient for plugin authors but terrible for end users. Every gateway restart requires re-exporting them, configuration isn't visible from the chat UI, and there's no way to inspect or edit settings without dropping into a terminal.
The convention: persistent state lives in a JSON file under $FLOWLY_HOME/<plugin-name>/config.json. Plugins read it on every relevant call (no caching needed — disk reads are cheap), and expose slash commands like /<plugin> add / remove / set for live editing from any channel.
Environment variables are still useful as overrides for one-off scenarios (CI, testing, admin debugging). They should never be the only way to configure a plugin.
Storage convention
One plugin = one directory, one file. The file lives at:
$FLOWLY_HOME/<plugin-name>/config.jsonFor example:
~/.flowly/auto-commit/config.json
~/.flowly/disk-cleanup/config.json
~/.flowly/spotify/credentials.json$FLOWLY_HOME resolves to the active profile's directory. So a user on the coder profile and the same user on researcher get separate plugin configs automatically — no extra work from the plugin author.Use get_state_dir() instead of hardcoding paths:
from pathlib import Path
def _get_state_dir() -> Path:
"""`$FLOWLY_HOME/<plugin-name>/` for config + audit logs + state."""
try:
from flowly.profile import get_flowly_home
return get_flowly_home() / "auto-commit"
except Exception:
# Defensive fallback for tests / standalone use
import os
val = (os.environ.get("FLOWLY_HOME") or "").strip()
base = Path(val).resolve() if val else (Path.home() / ".flowly").resolve()
return base / "auto-commit"
def _config_file() -> Path:
return _get_state_dir() / "config.json"Reading config
Read on every call that needs the config. Don't cache in module globals — if the user runs /your-plugin set foo=bar, they expect the change to apply immediately, not after a restart.
import json
def _load_config() -> dict:
"""Read the persistent config. Missing file → empty config."""
cf = _config_file()
if not cf.exists():
return {}
try:
data = json.loads(cf.read_text(encoding="utf-8"))
if isinstance(data, dict):
return data
except (json.JSONDecodeError, OSError):
pass
return {}Writing config — slash commands
Expose slash commands so users edit settings from chat instead of editing JSON by hand. Atomic writes via tmp file + rename:
def _save_config(cfg: dict) -> None:
"""Write config atomically. Directory created if missing."""
cf = _config_file()
cf.parent.mkdir(parents=True, exist_ok=True)
tmp = cf.with_suffix(".tmp")
tmp.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
tmp.replace(cf)Standard slash command pattern
Every settings-having plugin should expose four sub-commands:
/<plugin> status— show current settings and source (env vs config file)/<plugin> add <value>— for list-typed settings (allow-lists, feature flags)/<plugin> remove <value>— symmetric with add/<plugin> set <key> <value>— for scalar settings (timeout, model, mode)
def slash_handler(args: str) -> str:
argv = args.strip().split()
if not argv:
return _HELP_TEXT
sub = argv[0]
if sub == "status":
cfg = _load_config()
roots = cfg.get("paths") or []
if not roots:
return "DISABLED. Run `/auto-commit add <path>` to enable."
return "ENABLED. Allowed roots:\n" + "\n".join(f" • {r}" for r in roots)
if sub == "add":
if len(argv) < 2:
return "Usage: /auto-commit add <path>"
path = str(Path(argv[1]).expanduser().resolve())
cfg = _load_config()
paths = cfg.get("paths") or []
if path in paths:
return f"Already in allow-list: {path}"
paths.append(path)
cfg["paths"] = paths
_save_config(cfg)
return f"✓ Added: {path}"
if sub == "remove":
# symmetric with add
...
return f"Unknown subcommand: {sub}"See the bundled auto-commit plugin for the full implementation, including warnings for non-existent paths and non-git directories.
Env var override
Sometimes you want to override config without touching disk — CI runs, temporary admin testing, scripted deployments. Support an env var that takes precedence:
import os
def _allowed_roots() -> list[Path]:
"""Resolve the allow-list, env-var-first."""
raw = os.environ.get("FLOWLY_AUTO_COMMIT_PATHS", "").strip()
if raw:
sources = raw.split(":") # colon-separated, like $PATH
else:
cfg = _load_config()
sources = cfg.get("paths") or []
return [Path(s.strip()).expanduser().resolve() for s in sources if s.strip()]Precedence rules
When both sources exist, env var wins. The status command should make this clear:
def status_handler(args: str) -> str:
env_active = bool(os.environ.get("FLOWLY_AUTO_COMMIT_PATHS", "").strip())
source = "env var FLOWLY_AUTO_COMMIT_PATHS" if env_active else "config.json"
roots = _allowed_roots()
return f"ENABLED (source: {source}). Allowed roots:\n" + "\n".join(...)This way, when a user thinks "I added /foo via /add but it's not showing up", the status output points them to the env var override that's masking it.
Common patterns
List of paths (allow-list)
Used by auto-commit, disk-cleanup. Schema:
{
"paths": ["/Users/me/projects", "/Users/me/notes"]
}OAuth credentials
For plugins that need OAuth tokens (Spotify, Notion, Linear). Schema:
{
"access_token": "...",
"refresh_token": "...",
"expires_at": "2026-04-29T12:00:00Z",
"scopes": ["read", "write"]
}File mode 0600 recommended. Use a separate file (credentials.json) so non-secret config stays in config.json.
Feature flags / scalars
{
"enabled": true,
"mode": "quick",
"rate_limit_per_minute": 60,
"include_metadata": false
}Slash command: /your-plugin set rate_limit_per_minute 120. Always validate values before persisting (range checks, enum membership).
auto-commit plugin uses all three patterns: an allow-list (paths), an env var override (FLOWLY_AUTO_COMMIT_PATHS), and slash commands for live editing. Read its source at flowly/plugins_bundled/auto-commit/__init__.py.