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)"] -- ""how much disk space is left?"" --> 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:
- Parse the command line. A subcommand (
lesysbot install,list, …) is dispatched to the artifact CLI and exits — the bot never starts. - Load settings.
Settings.load()(lesysbot/core/config.py) finds the active config (see §7), then appliesLESYSBOT_*environment variables and CLI flags on top. - Resolve paths. Relative paths (
./tools,logs/…) are anchored to the config file's directory — so an install uses~/.lesysbot/toolsand a checkout uses the repo'stools/. - Set up logging. A Rich console handler plus a rotating file handler (see §9).
- Build the Agent.
Agent.setup()loads every tool from the tools directory and, withhot_reload: true, starts a watcher that reloads them whenever a.pyfile changes. - Pick the adapter. An
if/elifonmessaging.providerimports 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 thetelegram/discordextras, and picking a provider you haven't installed names the extra to add. - Wire confirmation.
agent.set_confirm_fn(adapter.confirm)connects the adapter'sy/nprompt or buttons, soconfirm=Truetools can ask first. The adapter'ssend()also goes to lesysbot/core/notify.py, the out-of-band push channel:notify_later(text, delay)messages the user after a reply — thepowertool announces "powering off now" with it. - 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 whyexitreally 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 awaitsadapter.confirm(...)— youry/nor 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:
| Backend | base_url | api_key |
|---|---|---|
| Ollama | http://localhost:11434/v1 | ollama (any string) |
| vLLM | http://localhost:8000/v1 | vllm (any string) |
| OpenAI | https://api.openai.com/v1 | your 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 amodels.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:
- Every non-
_-prefixed.pyfile directly intools/is imported (quick local tools). - 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 onsys.pathduring its load, so it canfrom _helpers import …without clashing with another package's helpers. - Anything exposing
__tool_meta__(set by@tool) or that is aCLIToolinstance 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 callawait 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 forconfirm=Truetools. 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
telegramextra (python-telegram-bot v20+), anallowed_user_idsallow-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
discordextra: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.
# 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:
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:
| Setup | Active config | ./tools resolves to |
|---|---|---|
| Installed (wizard) | ~/.lesysbot/config.yaml | ~/.lesysbot/tools/ |
| Dev checkout | ./config.yaml in the repo | the repo's tools/ |
| No config at all | built-in defaults | tools/ 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_countfiles kept). In an interactive CLI session the console only shows warnings so chat stays clean; the file always gets the configuredlogging.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;resultandreplyare 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 defaultapi_key: ollamaisn'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 type | You get |
|---|---|
lesysbot run | the service: control panel + bot (what every service template runs) |
lesysbot --provider … | the bot in the foreground, no panel |
lesysbot manage | the panel — or just its URL, when the service already serves it |
lesysbot | health 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… | Touch | Guide |
|---|---|---|
| Add a capability (new tool) | a new folder in tools/ — no core code | Write a tool |
| Share a tool with others | a GitHub repo — nothing else | Write a tool → Share it |
| Support a new chat platform | new file in lesysbot/messaging/ + one elif in lesysbot/main.py | §6 |
Change what appears in a platform's / menu | lesysbot/messaging/commands.py | this page, §6 |
| Support a new LLM backend | usually nothing — set llm.base_url | Settings |
| Change the tool-calling loop, history, confirmations | lesysbot/core/agent.py | this page, §3 |
| Change tool discovery, gating, hot reload | lesysbot/mcp/registry.py | this page, §5 |
| Add a config setting | lesysbot/core/config.py + config/default.yaml + configuration.md | CONTRIBUTING.md |
| Change the setup wizard | lesysbot/setup/ — the Python implementation; scripts/install.sh only bootstraps into it | CONTRIBUTING.md |
| Change the control panel | lesysbot/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.