Architecture Overview

discli is a Python CLI built on Click and discord.py. It operates in two distinct modes: fire-and-forget CLI commands for one-off operations, and a persistent serve mode for real-time bidirectional communication with AI agents.

High-level request flow

Every discli invocation follows this pipeline, from user input to Discord API call:

flowchart LR
    A[User / Agent] --> B[discli CLI]
    B --> C[Click Framework]
    C --> D[Permission Check]
    D --> E[Token Resolution]
    E --> F[discord.py Client]
    F --> G[Discord API]
  1. The user (or an AI agent) invokes a discli command.
  2. Click parses arguments and global options (--token, --json, --yes, --profile).
  3. The active permission profile is checked to confirm the command is allowed.
  4. The bot token is resolved from flag, environment variable, or config file.
  5. One-shot commands authenticate an HTTP client and execute directly. Live commands connect a Gateway client with feature-specific intents (see Connection Model).
  6. The Discord API processes the request.

Module dependency graph

flowchart TD
    CLI[cli.py] --> CMD[commands/*]
    CLI --> PERM[permission group]
    CLI --> AUDIT[audit group]
    CMD --> CLIENT[client.py]
    CLIENT --> SEC[security.py]
    CLIENT --> DISCORD[discord.py]
    CMD --> UTILS[utils.py]
    CMD --> SEC
    CLI --> CFG[config.py]
    SEC --> CFG_DIR["~/.discli/"]
    CFG --> CFG_DIR

    style CLI fill:#4f46e5,color:#fff
    style CMD fill:#7c3aed,color:#fff
    style CLIENT fill:#2563eb,color:#fff
    style SEC fill:#dc2626,color:#fff
    style CFG fill:#059669,color:#fff
    style UTILS fill:#d97706,color:#fff

Module roles

ModuleFilePurpose
CLI entry pointcli.pyClick root group. Registers all command groups, defines global options, hosts permission and audit subcommands.
Commandscommands/*.pyEach file defines a Click command group. One-shot actions use run_rest(); live state and voice actions use the Gateway path.
Clientclient.pyResolves tokens, separates REST and Gateway lifecycles, enforces permissions, and builds the minimum intents for Gateway features.
Securitysecurity.pyPermission profiles (allow/deny lists), is_command_allowed() enforcement, audit_log() JSONL writer, RateLimiter (token bucket), check_user_permission() for Discord-level permission checks, destructive action confirmation.
Configconfig.pyReads/writes ~/.discli/config.json. Handles token persistence and config merging.
Utilsutils.pyoutput() respects the --json flag. Async resource resolvers translate names/IDs with cache-first lookups and REST fallbacks.
Servecommands/serve.pyPersistent bot mode. Reads JSONL from stdin, writes JSONL to stdout. ~2,200 lines covering 65+ actions, event forwarding, slash command registration, and streaming with 1.5s flush intervals.

Two operating modes

Mode 1: Fire-and-forget CLI commands

This is the default mode. Each command creates a short-lived HTTP-authenticated discord.Client, performs one operation, and exits without opening a Gateway session.

sequenceDiagram
    participant User
    participant CLI as discli CLI
    participant Client as discord.Client
    participant API as Discord API

    User->>CLI: discli message send "#general" "Hello"
    CLI->>CLI: Parse args, check permissions, resolve token
    CLI->>Client: Create client and authenticate token
    Client->>API: HTTP request to send message
    API-->>Client: 200 OK
    Client->>Client: Close connection
    CLI-->>User: "Message sent to #general"

This approach is simple and stateless. It is subject to Discord HTTP rate limits, but it does not send Gateway IDENTIFY, consume session-start quota, or require unrelated Gateway intents.

Best for: scripting, cron jobs, one-off operations, piping into other tools.

voice commands are the exception to this mode: they need live voice state, so they open a Gateway session scoped to the voice feature and close it when the action finishes. See Connection Model for the full REST/Gateway split and the intents each feature requests.

Mode 2: Persistent serve mode

discli serve starts a long-lived bot that communicates over stdin/stdout using newline-delimited JSON (JSONL). The bot stays connected to Discord’s gateway and forwards events in real time.

sequenceDiagram
    participant Agent as AI Agent
    participant Serve as discli serve
    participant API as Discord API

    Serve->>API: Gateway connect
    API-->>Serve: on_ready
    Serve-->>Agent: {"event":"ready","bot_name":"..."}

    API-->>Serve: New message event
    Serve-->>Agent: {"event":"message","content":"Hi bot",...}

    Agent->>Serve: {"action":"reply","channel_id":"...","message_id":"...","content":"Hello!"}
    Serve->>API: Send reply
    API-->>Serve: 200 OK
    Serve-->>Agent: {"event":"response","status":"ok","message_id":"..."}

Serve mode maintains persistent state:

  • Typing indicators per channel (start/stop via actions)
  • Streaming edits that batch content updates every 1.5 seconds to stay within Discord rate limits
  • Slash command registrations synced per guild on startup
  • Interaction tokens for deferred slash command responses

Best for: AI agents, chatbots, real-time monitoring, interactive applications.

Info

Serve mode uses a thread-based stdin reader instead of asyncio.connect_read_pipe because the latter fails on Windows when stdin is a pipe from a parent process. The reader thread puts lines into a queue.Queue, and the asyncio event loop polls it via run_in_executor.

Command registration

All command groups are registered in cli.py via main.add_command():

main.add_command(channel_group)
main.add_command(config_group)
main.add_command(dm_group)
main.add_command(listen_cmd)
main.add_command(member_group)
main.add_command(message_group)
main.add_command(reaction_group)
main.add_command(role_group)
main.add_command(server_group)
main.add_command(poll_group)
main.add_command(thread_group)
main.add_command(typing_cmd)
main.add_command(serve_cmd)
main.add_command(permission_group)
main.add_command(audit_group)

Each command group maps to a file in commands/. Adding a new command group follows the same pattern: define Click commands in a new file, register the group in cli.py.

Key design decisions

DecisionRationale
Click over argparseClick provides nested command groups, automatic help generation, and context passing — a natural fit for discli’s discli <group> <command> structure.
Temporary client per CLI commandKeeps commands stateless and avoids connection management. The 1-2s overhead is acceptable for scripting use cases.
JSONL for serve protocolNewline-delimited JSON is trivial to parse in any language, streamable, and unambiguous. Every agent framework can read/write it.
Permission profiles at the CLI layerPrevents accidental misuse before any Discord API call is made. An agent with a readonly profile cannot send messages regardless of the bot’s Discord permissions.
Separate security moduleCentralizes permission checks, audit logging, and rate limiting so every command path goes through the same enforcement.

Next steps