Configuration

Plugin settings

Plugins that need user input — allow-listed paths, API keys, feature flags — should store that input as persistent config, not environment variables. This page documents the convention every plugin should follow so users get a consistent experience.

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:

text
$FLOWLY_HOME/<plugin-name>/config.json

For example:

text
~/.flowly/auto-commit/config.json
~/.flowly/disk-cleanup/config.json
~/.flowly/spotify/credentials.json
Profile-aware
$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:

python
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.

python
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 {}
Always handle missing/corrupt files gracefully. Returning an empty dict means "no settings", which usually translates to plugin-dormant. Never raise — the agent loop continues regardless.

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:

python
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)
python
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:

python
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()]
Don't rely on env var alone
Env var override is for power users and automation. The default path — and your documentation's first example — should always be the slash command. New users shouldn't need to know about env vars.

Precedence rules

When both sources exist, env var wins. The status command should make this clear:

python
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:

json
{
  "paths": ["/Users/me/projects", "/Users/me/notes"]
}

OAuth credentials

For plugins that need OAuth tokens (Spotify, Notion, Linear). Schema:

json
{
  "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

json
{
  "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).

Reference implementation
The bundled 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.