Slash commands deep dive
/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 statusreads a JSON file and formats it. No model call, no token spend, sub-millisecond response. - Power-user shortcuts for common workflows.
/journal entry textappends 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
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.
/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:
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:
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:
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 flagsResponse 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.
# ❌ 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## Headingfor record titles when showing one item- Tables for structured records (key/value, listings with columns)
- bulletfor 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.
Async handlers
Slash handlers can be async. The dispatcher awaits them automatically.
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)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.
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:
_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 dispatchThree things to include in every help text:
- The full subcommand list with one-line descriptions.
- Where the plugin's state lives (so users can find logs/config).
- 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 allregister_commandentries with theirdescription.flowly plugins list— the CLI shows command counts per plugin.- Channel-native pickers (Discord) — uses the
args_hintfield if you provided it.
'/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.