Slash commands

Slash commands deep dive

Slash commands are user-triggered actions that bypass the LLM entirely. The user types /your-cmd args, your handler runs, and the response goes straight back to the chat. They're available in every channel — Telegram, Web, Desktop, iOS — and they're one of the cheapest ways to add deterministic, fast functionality to your plugin.

When to add a slash command

Three good fits, and three bad ones.

Good fits

  • Manual control over plugin behavior. Configure which directories are auto-committed, start/stop a focus session, clear an audit log.
  • Quick lookups that don't need the LLM. /disk-cleanup status reads a JSON file and formats it. No model call, no token spend, sub-millisecond response.
  • Power-user shortcuts for common workflows. /journal entry text appends to today's file in one step instead of dictating the workflow to the agent.

Bad fits

  • Anything the agent could decide on its own. If the user could just say "clean up disk" in natural language and the agent would call the right tool, prefer a tool over a slash command.
  • Long-running operations. Slash handlers are synchronous from the user's perspective. Anything over a few seconds should be a tool the agent kicks off and awaits.
  • Operations that need to be discoverable by the LLM. The LLM can't call slash commands. If the agent should know about this capability and chain it with other actions, it's a tool.

Basic registration

python
def register(ctx):
    def ping(args: str) -> str:
        return "pong" + (f" {args}" if args else "")

    ctx.register_command(
        "ping",
        handler=ping,
        description="Round-trip latency check",
        args_hint="[message]",
    )

The name is normalized: lowercased and spaces become hyphens. /Foo Bar becomes /foo-bar.

description shows up in /help and the desktop command picker. args_hint is a short string surfaced by gateways that have native command UIs (Discord) — keep it under 30 characters.

Reserved names
/new, /clear, /compact, /help are owned by Flowly core. If you try to register them, the manager logs a warning and rejects the call — your handler is never wired up.

Argument parsing

Your handler receives everything after the command name as a single string. No quoting, no escaping, no automatic split. You parse it.

Simple subcommand pattern

For most plugins, a leading-word subcommand pattern is enough:

python
def handler(args: str) -> str:
    argv = args.strip().split()  # whitespace tokens
    if not argv or argv[0] in ("help", "-h", "--help"):
        return _HELP_TEXT

    sub = argv[0]
    rest = argv[1:]

    if sub == "status":
        return show_status()

    if sub == "add":
        if len(rest) < 1:
            return "Usage: /your-cmd add <value>"
        return add_value(rest[0])

    if sub == "set":
        if len(rest) < 2:
            return "Usage: /your-cmd set <key> <value>"
        return set_kv(rest[0], " ".join(rest[1:]))

    return f"Unknown subcommand: {sub}\n\n{_HELP_TEXT}"

When you need quoted args

For commands like /journal entry "had coffee with John", use shlex.split:

python
import shlex

def handler(args: str) -> str:
    try:
        argv = shlex.split(args.strip(), posix=True)
    except ValueError:
        return "Mismatched quotes — try again."

    if not argv:
        return _HELP_TEXT
    # ...

Key:value flag style

For commands with many optional flags (e.g. /search query:foo limit:10 sort:newest), parse key:value tokens:

python
def parse_flags(args: str) -> dict:
    flags = {}
    for tok in args.strip().split():
        if ":" in tok:
            key, _, val = tok.partition(":")
            flags[key.strip()] = val.strip()
    return flags

Response format

The handler's return value goes straight to the user as a chat message.

  • Return a str: sent as the response. Markdown is rendered in channels that support it (Telegram, Discord, Web, Desktop).
  • Return None: fire-and-forget. Useful for "mark as done"-type commands where silence is the success signal.
  • Return any other type: coerced to string via str(). Don't rely on this — be explicit.

Formatting

Always use Markdown. The desktop app and most channels (Telegram, Discord, Web) render it natively — bold, headings, tables, inline code, bullet lists all work. Plain text returns get displayed as-is and look like raw output dumps in the chat thread.

python
# ❌ Bad — looks like a terminal log in the chat
def status_bad(args: str) -> str:
    return (
        "auto-commit is ENABLED. Allowed roots:\n"
        "  • /Users/me/projects (git repo)\n"
        "  • /Users/me/notes    (git repo)"
    )


# ✅ Good — Markdown renders properly
def status_good(args: str) -> str:
    return (
        "**auto-commit is ENABLED**\n\n"
        "| Root | Status |\n"
        "|---|---|\n"
        "| `/Users/me/projects` | 🟢 git repo |\n"
        "| `/Users/me/notes` | 🟢 git repo |"
    )

Useful Markdown elements for slash output:

  • **bold** for status / labels / counts
  • `code` for IDs, paths, commands, technical values
  • ## Heading for record titles when showing one item
  • Tables for structured records (key/value, listings with columns)
  • - bullet for lists (each line; use trailing two-space for line breaks)
  • _italic_ for empty-state messages ("_no results_")
  • Avoid HTML — not all channels render it

See the bundled case-tracker plugin for a real-world example of slash handlers returning rich Markdown — tables for one-record details, bullet lists for search results, code spans for IDs, headings for sections.

Response length
Keep responses under 4000 characters when possible — Telegram caps at ~4096, and long messages are unpleasant to read on mobile. For long output, return a summary plus "run /your-cmd log 50 for details".

Async handlers

Slash handlers can be async. The dispatcher awaits them automatically.

python
import httpx

async def fetch_handler(args: str) -> str:
    """/fetch <url> — fetch a URL and return its title."""
    url = args.strip()
    if not url:
        return "Usage: /fetch <url>"

    async with httpx.AsyncClient(timeout=10) as client:
        try:
            r = await client.get(url)
        except httpx.RequestError as exc:
            return f"Fetch failed: {exc}"

    return f"{url} → status {r.status_code}, {len(r.text)} bytes"

def register(ctx):
    ctx.register_command("fetch", handler=fetch_handler)
Don't block forever
Always set a timeout for network calls. A handler that hangs blocks the agent loop for that channel until it returns or raises. 10–15 seconds is a sensible upper bound; for anything longer, kick off the work with a tool call and let the agent await.

Error handling

The dispatcher wraps your handler in a try/except. If you raise, the framework catches it, logs the traceback, and sends a generic error to the user. That's a poor UX — catch your own errors and return a useful message.

python
def handler(args: str) -> str:
    try:
        result = do_the_work(args)
    except ValueError as exc:
        return f"Invalid input: {exc}"
    except FileNotFoundError as exc:
        return f"File not found: {exc.filename}"
    except Exception as exc:
        # Last-resort catch-all so the user sees something useful
        return f"⚠️  Unexpected error: {type(exc).__name__}: {exc}"

    return f"✓ Done: {result}"

For commands that hit external services, distinguish "your input was wrong" from "the service is down" in the message — it changes whether the user retries.

Building a /help subcommand

Convention: the bare command (or help / -h / --help) returns a help text:

python
_HELP_TEXT = """\
/auto-commit — automatic git commits

Subcommands:
  status                 Show enabled state and allowed roots
  add <path>             Add a path to the persistent allow-list
  remove <path>          Remove a path from the allow-list
  log [count]            Show recent commit events (default: 20)
  help                   This help

The allow-list lives at $FLOWLY_HOME/auto-commit/config.json and
persists across gateway restarts. Changes apply immediately.
"""

def handler(args: str) -> str:
    argv = args.strip().split()
    if not argv or argv[0] in ("help", "-h", "--help"):
        return _HELP_TEXT
    # ... subcommand dispatch

Three things to include in every help text:

  1. The full subcommand list with one-line descriptions.
  2. Where the plugin's state lives (so users can find logs/config).
  3. Whether config changes need a gateway restart.

How users discover commands

Slash commands you register show up in three places:

  • /help — Flowly's built-in help command appends a "Plugin commands" section listing all register_command entries with their description.
  • flowly plugins list — the CLI shows command counts per plugin.
  • Channel-native pickers (Discord) — uses the args_hint field if you provided it.
Desktop UI gotcha
The Flowly desktop app maintains its own slash whitelist for autocomplete and may reject unknown commands at the input layer with "Unknown command". Workaround for users: prefix with a single quote ('/your-cmd args). The desktop treats it as a regular message and the gateway-side dispatcher picks it up. We're working on a fix.