LeSysBot docs

This is the technical page. You don't need any of it to use LeSysBot — Everyday use is the one for that. Read this when you want to modify the code or contribute, then continue to CONTRIBUTING.md.

What happens inside LeSysBot, from the message you send to the reply you get — top-down: the big picture, one message step by step, each layer in detail, then a map of where to change what.


1. The big picture#

LeSysBot is three independent layers wired together by one class, Agent:

flowchart TD
    you["you (CLI / Telegram / Discord)"] -- "&quot;how much disk space is left?&quot;" --> adapter["MessagingAdapter<br>(how you reach it)"]
    adapter --> agent["Agent<br>(the middleman)"]
    agent --> llm["LLMClient<br>(the model)"]
    agent -- "run a tool" --> registry["ToolRegistry<br>(what it can do)"]
    registry -- "tool result" --> agent
  • MessagingAdapter (lesysbot/messaging/) — where messages come from and where replies go: your terminal, Telegram, or Discord.
  • Agent (lesysbot/core/agent.py) — the middleman. It keeps per-user conversation history, asks the LLM what to do, runs the tools the LLM asks for, and loops until there's a final answer.
  • LLMClient (lesysbot/llm/client.py) — a thin wrapper around one OpenAI-compatible API. Ollama, vLLM, LlamaCpp, and OpenAI all speak this protocol, so there is no backend-specific code anywhere.
  • ToolRegistry (lesysbot/mcp/registry.py) — the catalog of tools loaded from your tools/ folder, with hot reload.

The layers only meet inside Agent, so each can be swapped or extended without touching the others: a new chat platform is just a new adapter, a new LLM backend is just a different base_url, and a new capability is just a file dropped into tools/.


2. Startup — from lesysbot to a running bot#

Everything starts in lesysbot/main.py:

  1. Parse the command line. A subcommand (lesysbot install, list, …) is dispatched to the artifact CLI and exits — the bot never starts.
  2. Load settings. Settings.load() (lesysbot/core/config.py) finds the active config (see §7), then applies LESYSBOT_* environment variables and CLI flags on top.
  3. Resolve paths. Relative paths (./tools, logs/…) are anchored to the config file's directory — so an install uses ~/.lesysbot/tools and a checkout uses the repo's tools/.
  4. Set up logging. A Rich console handler plus a rotating file handler (see §9).
  5. Build the Agent. Agent.setup() loads every tool from the tools directory and, with hot_reload: true, starts a watcher that reloads them whenever a .py file changes.
  6. Pick the adapter. An if/elif on messaging.provider imports and constructs the CLI, Telegram, or Discord adapter. Adapters are imported lazily so a missing optional dependency doesn't break the others — the Telegram and Discord packages are the telegram/discord extras, and picking a provider you haven't installed names the extra to add.
  7. Wire confirmation. agent.set_confirm_fn(adapter.confirm) connects the adapter's y/n prompt or buttons, so confirm=True tools can ask first. The adapter's send() also goes to lesysbot/core/notify.py, the out-of-band push channel: notify_later(text, delay) messages the user after a reply — the power tool announces "powering off now" with it.
  8. Run. await adapter.start(agent.handle) blocks for the life of the process. The startup notice runs beside it as a background task (Telegram/Discord only): it waits for the adapter to connect, then sends a short system report, so a service starting at boot tells you the machine came up (see Background service). It is cancelled when the adapter stops, which is why exit really ends the process.

3. The life of one message#

This is the heart of LeSysBot — Agent.handle(user_id, text) in lesysbot/core/agent.py. Every message from every adapter goes through the same steps:

sequenceDiagram
    actor you
    participant adapter as MessagingAdapter
    participant agent as Agent
    participant llm as LLMClient
    participant registry as ToolRegistry

    you->>adapter: "how much disk space is left?"
    adapter->>agent: handle(user_id, text)
    Note over agent: starts with "/" ? run the tool<br>directly — the LLM is never called
    Note over agent: otherwise take this user's turn lock —<br>one conversation advances one turn at a time
    agent->>agent: append to ConversationHistory
    loop until the model answers in text (max agent.max_tool_calls rounds)
        agent->>llm: chat(history, tool schemas)
        llm-->>agent: tool_calls: disk_usage(path='/')
        opt tool marked confirm=True
            agent->>you: adapter.confirm(…) — y/n or buttons
        end
        agent->>registry: run tools (parallel via asyncio.gather)
        registry-->>agent: results, appended to history
    end
    llm-->>agent: streamed final answer
    agent-->>adapter: reply
    adapter-->>you: "You have 42 GB free."

Step 1 — Slash commands take a shortcut. If the text starts with /, it's dispatched straight to _handle_slash(): the tool runs immediately and the LLM is never called. This is why /disk_usage path=/ works even when no model is running, and why slash commands don't appear in conversation history.

Step 2 — The message joins the history. Each user has their own ConversationHistory, seeded with the system prompt from the config and trimmed to agent.max_history messages.

Steps 2–6 run under a per-user lock, so one conversation advances one turn at a time; a message arriving mid-turn waits. This matters because the remote adapters dispatch updates concurrently — Telegram must, or the button answering a confirmation queues behind the handler awaiting it. Without the lock, two turns appended into the same history, each sent the other's messages to the model, and the model read the interleaving as an unfinished tool call and ran the tool twice. The lock is per user, so a slow call for one person doesn't stall anyone else, and Step 1 stays outside it — /cancel_shutdown must stay answerable while the turn that scheduled the reboot is still running.

Step 3 — Ask the LLM. The agent sends the whole history to LLMClient.chat(), along with a JSON schema for every enabled tool. The model now has a choice: answer in text, or ask for tool calls.

Step 4 — If the model answered in text, we're done. The streamed text is the reply; the adapter shows it to you.

Step 5 — If the model asked for tools, run them. For each requested call:

  • If the tool is marked confirm=True (or a custom message), the agent first awaits adapter.confirm(...) — your y/n or button tap. Declined calls return "Cancelled by user." to the model instead of running.
  • Tool calls run in parallel (asyncio.gather) unless any of them needs confirmation, in which case they run one at a time so each prompt can be answered cleanly.

Step 6 — Feed the results back and loop. Each tool result is appended to the history as a tool message, and the agent goes back to Step 3 so the model can interpret the results, call more tools, or write the final answer. The loop is capped at agent.max_tool_calls rounds (default 10) so a confused model can't spin forever.

If anything fails — the backend is down, the model doesn't exist — the user gets a friendly LLM unavailable: … message reminding them that / commands still work, and the full traceback goes to the log at DEBUG level.

Along the way, three optional callbacks keep the CLI display live: on_status drives the Thinking… / Running <tool>… spinner, on_token streams the answer text, and on_reasoning streams a reasoning model's thinking. Adapters that don't pass them (Telegram, Discord) are simply unaffected.


4. The LLM layer — one client for every backend#

lesysbot/llm/client.py holds a single AsyncOpenAI client with a configurable base_url. That's the whole trick: Ollama, vLLM, and LlamaCpp all expose an OpenAI-compatible API, so switching backends is a config change, not a code change:

Backendbase_urlapi_key
Ollamahttp://localhost:11434/v1ollama (any string)
vLLMhttp://localhost:8000/v1vllm (any string)
OpenAIhttps://api.openai.com/v1your real key

Details worth knowing before you modify it:

  • It always streams (stream=True), accumulating text and tool-call fragments from the deltas — that's what makes live rendering possible.
  • health() is a separate non-streaming probe: it times a models.list() call with a short 5 s timeout and reports whether the backend is reachable and whether your configured model is present.

5. The tool layer — registry, decorator, gating#

5.1 What a tool is#

A tool is a Python function (or a wrapped shell command) with a name, a description, and a JSON schema for its parameters. The LLM sees the schemas and picks tools by name; the /slash dispatcher uses the same catalog.

Two ways to define one (full guide: Write a tool):

  • @tool (lesysbot/mcp/decorators.py) — decorates a Python function and builds the parameter schema from its type hints. Sync functions are wrapped in async automatically.
  • CLITool (lesysbot/mcp/cli_tool.py) — wraps a shell command template ("ping -c 3 {host}") with named parameters and a timeout.

5.2 How tools are discovered#

At startup (and on every hot reload), ToolRegistry.load_directory() (lesysbot/mcp/registry.py) scans the tools directory:

  1. Every non-_-prefixed .py file directly in tools/ is imported (quick local tools).
  2. Every subdirectory is loaded as a folder package — the shareable form with its own README.md, tool.py, and optional _helpers.py. Each package's directory is put on sys.path during its load, so it can from _helpers import … without clashing with another package's helpers.
  3. Anything exposing __tool_meta__ (set by @tool) or that is a CLITool instance gets registered.

With mcp.hot_reload: true (the default), a watchfiles watcher re-runs this whenever a .py under tools/ changes — save a file and the tool is live.

5.3 Requirement gating#

Tools can declare requires=["nvidia-smi", …] (PATH binaries, checked with shutil.which in lesysbot/mcp/requirements.py). A tool that can't run on the current machine is still registered — it shows up in /help and to the LLM — but calling it returns a one-line explanation instead of a cryptic failure. Pip dependencies are not requires entries; tools import them and handle ImportError themselves. There is no OS gating: LeSysBot targets Linux only, so every tool that installs is a tool that runs.

5.4 Enable/disable#

lesysbot enable/disable toggles tools off. A disabled tool is hidden from the LLM's schemas and refuses direct / calls; the choice is persisted to tool_state.json (mcp.state_file) so it survives restarts and hot reloads. The running bot watches that file, so a change from the CLI applies within a second without a restart.


6. The messaging layer — adapters#

Every adapter implements the same tiny interface (lesysbot/messaging/base.py):

  • start(handler) — connect to the platform and call await handler(user_id, text) for each incoming message.
  • send(user_id, text) — deliver a reply.
  • confirm(user_id, tool_name, prompt, args) — optional; show a confirmation UI for confirm=True tools. The default auto-approves.

The three built-ins:

  • CLI (lesysbot/messaging/cli.py) — reads stdin, renders streamed answers as live Markdown with a status spinner, and prints slash-command output verbatim so column layouts survive.
  • Telegram (lesysbot/messaging/telegram.py) — the telegram extra (python-telegram-bot v20+), an allowed_user_ids allow-list, and ✅/❌ inline buttons for confirmation. Malformed Markdown falls back to plain text so no reply is ever dropped.
  • Discord (lesysbot/messaging/discord.py) — gateway websocket; DMs, plus channel messages that @-mention the bot. ✅/❌ button confirmations (the discord extra: discord.py).

Both remote adapters also publish the tool list as native platform slash commands — Telegram's / menu, Discord's typed command picker — built from one set of specs in lesysbot/messaging/commands.py. Those commands rebuild the /name key=value text and re-enter Agent._handle_slash, so there is still exactly one place a tool call is dispatched.

Adding a chat platform#

Subclass MessagingAdapter, implement start() and send(), and override confirm() if the platform can show a yes/no prompt. split_message() in the same module chunks long replies to the platform's length limit.

python
# lesysbot/messaging/myplatform.py
from lesysbot.messaging.base import MessageHandler, MessagingAdapter

class MyPlatformAdapter(MessagingAdapter):
    async def start(self, handler: MessageHandler) -> None:
        async for user_id, text in my_platform.listen():
            await self.send(user_id, await handler(user_id, text))

    async def send(self, user_id: str, text: str) -> None:
        await my_platform.send_message(user_id, text)

    async def confirm(self, user_id, tool_name, prompt, args) -> bool:
        return await my_platform.ask_yes_no(user_id, prompt)

Wire it into the if/elif block in _run() in lesysbot/__main__.py — the existing agent.set_confirm_fn(adapter.confirm) call then uses your prompt:

python
elif provider == "myplatform":
    from lesysbot.messaging.myplatform import MyPlatformAdapter
    adapter = MyPlatformAdapter(settings.messaging.myplatform)

For a native command menu, take the registry as a second constructor argument and build it from all_commands(registry) in commands.py; render each invocation back with to_slash_text(name, kwargs) so it goes through the one dispatch path. The PR checklist is in CONTRIBUTING.md §5.


7. Configuration & paths#

Two modules decide which settings apply and where files live:

lesysbot/core/config.py — Settings.load() searches, in order: the -c flag → ./config.yaml → ~/.lesysbot/config.yaml (what the installer writes) → the bundled config/default.yaml → built-in defaults. Every field can also be overridden by a LESYSBOT_ environment variable (LESYSBOT_LLM__MODEL=…, __ = nesting) and by CLI flags, in that order of increasing precedence. The loaded file's directory is remembered as config_dir.

lesysbot/core/paths.py — relative paths in the config (./tools, logs/lesysbot.log) are anchored to that config_dir. This one rule makes every deployment shape work unchanged:

SetupActive config./tools resolves to
Installed (wizard)~/.lesysbot/config.yaml~/.lesysbot/tools/
Dev checkout./config.yaml in the repothe repo's tools/
No config at allbuilt-in defaultstools/ under the working directory

~/.lesysbot/ is the stable per-user home (override with LESYSBOT_HOME); the full reference is in Configuration.


8. The tool installer#

lesysbot install owner/repo (lesysbot/artifacts/) downloads a package from GitHub into the same directory the bot loads, so a running bot picks it up via hot reload. One verb installs tools and dashboards: the manifest decides which, and each lands in its own directory. The pipeline, one module per stage:

flowchart LR
    spec["spec.py<br>parse the<br>source spec"] --> fetch["fetch.py<br>download the zipball<br>(HTTPS, no git binary)"] --> archive["archive.py<br>extract with zip-slip/<br>symlink/size guards"] --> manifest["manifest.py<br>read README frontmatter<br>(no package code imported)"] --> installer["installer.py<br>move into place, record<br>provenance in the lock file"]

User guide: Install tools. Trust model: Security.


9. Logging & tracing#

Two independent records of what happened (paths anchored like everything else — ~/.lesysbot/logs/ when installed):

  • logs/lesysbot.log — the plain-text application log, rotated on a timer (logging.when, default midnight; logging.backup_count files kept). In an interactive CLI session the console only shows warnings so chat stays clean; the file always gets the configured logging.level.

  • logs/traces.jsonl (lesysbot/core/trace.py) — one JSON line per user message: every LLM turn, every tool call with its arguments and duration, and the final reply. This is the first place to look when you're debugging what the model decided to do. / commands are not traced; result and reply are cut at 2000 characters:

    json
    {"ts": "2026-06-21T12:00:00+00:00", "trace_id": "ab29aaf98c9b", "user_id": "cli-user",
     "input": "what is my disk usage?",
     "turns": [{"index": 1, "model": "qwen3.5:4b", "response_type": "tool_calls", "ms": 840.0,
                "tools": [{"name": "disk_usage", "args": {"path": "/"}, "result": "…", "ms": 42.5}]},
               {"index": 2, "model": "qwen3.5:4b", "response_type": "text", "ms": 620.0, "tools": []}],
     "reply": "Your disk at / is 80% full with 40 GB free.", "total_ms": 1460.0}

    Both files pass through a redacting filter (lesysbot/core/redact.py): token shapes (Telegram, Discord, OpenAI sk-) are masked with no configuration, and the exact secrets from the active config are registered at startup. Values under 12 characters are left alone, so the default api_key: ollama isn't rewritten everywhere.


10. The control panel and CLI dispatch#

The one network listener in the project is the control panel in lesysbot/management/ — a stdlib ThreadingHTTPServer bound to 127.0.0.1 only, with a DNS-rebinding guard that rejects any request whose Host header isn't loopback. It has no authentication: the trust boundary is a shell on the machine, the same access as editing config.yaml. The single-page UI is inlined as a Python string, so it ships with the package and adds no dependencies.

It runs inside the service. lesysbot run calls serve_background(), which serves from a daemon thread sharing the bot's own ToolRegistry — so a toggle in the browser hits the registry the LLM is using. It binds the configured port exactly, never the next free one, so the panel stays at the address people bookmark; a busy port is logged and skipped rather than taking the service down. lesysbot manage is the fallback when no service is running.

It exposes GET /api/status, /api/tools, /api/config and POST /api/config, /api/tools/{toggle,install,remove}. Config writes are validated against the settings schema before the file is touched. Toggling a tool goes through registry.set_enabled(), which persists to mcp.state_file — the same file the lesysbot install CLI writes, and the one a running bot watches, which is why a toggle applies live while other settings need a restart.

Which thing does lesysbot start? __main__.main() decides:

You typeYou get
lesysbot runthe service: control panel + bot (what every service template runs)
lesysbot --provider …the bot in the foreground, no panel
lesysbot managethe panel — or just its URL, when the service already serves it
lesysbothealth and metrics, then exit — starts nothing

Bare lesysbot is a read-only view precisely because the panel no longer needs starting: it's already there. With a cli provider the service has no channel to poll, so it serves the panel and idles.

The status snapshot behind both the terminal view and /api/status lives in lesysbot/core/status.py. It probes the panel (/api/ping, which identifies our server rather than trusting whatever holds the port) and the dashboard stack, and reports the service by testing the single-instance lock — a leftover lock file with a stale PID must not read as "running".


11. Where to change what#

I want to…TouchGuide
Add a capability (new tool)a new folder in tools/ — no core codeWrite a tool
Share a tool with othersa GitHub repo — nothing elseWrite a tool → Share it
Support a new chat platformnew file in lesysbot/messaging/ + one elif in lesysbot/main.py§6
Change what appears in a platform's / menulesysbot/messaging/commands.pythis page, §6
Support a new LLM backendusually nothing — set llm.base_urlSettings
Change the tool-calling loop, history, confirmationslesysbot/core/agent.pythis page, §3
Change tool discovery, gating, hot reloadlesysbot/mcp/registry.pythis page, §5
Add a config settinglesysbot/core/config.py + config/default.yaml + configuration.mdCONTRIBUTING.md
Change the setup wizardlesysbot/setup/ — the Python implementation; scripts/install.sh only bootstraps into itCONTRIBUTING.md
Change the control panellesysbot/management/this page, §10

Next steps#

  • Ready to make a change? Follow the step-by-step in CONTRIBUTING.md — dev setup, tests, lint, PR checklist.
  • Writing a tool is the gentlest entry point: Write a tool.
  • The AI-assistant-oriented notes in CLAUDE.md cover the same ground at a finer grain (module internals, edge cases) if you need more depth.