# kbots — Architecture & Operations Reference > Last updated: 2026-08-10 ## System Overview A single async Python process is the whole of kbots: it bridges Discord bots to Claude Code CLI sessions and layers on MCP-exposed tools, persistent memory, an encrypted credential vault, three-layer access control, and a complete security middleware stack. One checkout can power several instances, each started with its own `--profile` config — for example a locked-down production service alongside an unrestricted dev/ops one. All instances read and write the same SQLite databases (safe to share thanks to WAL mode plus busy_timeout) and open the same Fernet vault. --- ## Architecture ``` Discord (one or more bot accounts) │ ▼ ┌─────────────────────────────────────────────────────────┐ │ kbots Process │ │ │ │ Discord Connector (multi-bot, typing, slash commands, │ │ loop detection, dedup, reply fallback) │ │ │ │ │ ▼ │ │ Router (channel/guild/bot/category → agent mapping) │ │ │ │ │ ▼ │ │ Access Control (three-layer) │ │ ├── Layer 1: Can they talk? (sender tier → agent tier) │ │ ├── Layer 2: What tools? (--disallowedTools per sender)│ │ └── Layer 3: Agent ceiling (static config per agent) │ │ │ │ │ ▼ │ │ Agent Manager │ │ ├── Context injection (channel, user, attachments) │ │ ├── Auto-recall (memory search before each LLM call) │ │ ├── Session management (SQLite, --resume) │ │ └── Auto-summarize (long conversations) │ │ │ │ │ ▼ │ │ Claude Code CLI (--print --output-format json) │ │ ├── Reads agent identity (AGENTS.md) from project_dir │ │ ├── Built-in tools: Read, Write, Bash, Glob, Grep, │ │ │ WebSearch, WebFetch (blockable via disallow_builtins)│ │ └── MCP tools: custom tools via kbots-tools server │ │ │ │ │ ▼ │ │ MCP Server (FastMCP SDK, stdio transport) │ │ ├── Rate limit check │ │ ├── HITL gate (Discord reaction approval) │ │ ├── Tool execution (@tool Python functions) │ │ ├── Audit log (JSONL) │ │ └── Vault access (Fernet encrypted credentials) │ │ │ │ Storage: SQLite (sessions, messages, tool logs) │ │ Hot reload: file watcher on skills/ and src/tools/ │ └─────────────────────────────────────────────────────────┘ │ ▼ External APIs (whatever you connect) • Google (OAuth2) • Discord API • Tavily, Groq, Gemini • Trello, Notion, Cloudflare • Your own integrations ``` ## Three-Layer Architecture The directory structure is split into layers so that the engine, any domain extensions, and each deployment's own config live apart from one another. The payoff is a Core repo that stays easy to update and a public codebase that deployment data can never bleed into. ``` Layer 1 (Core): $KBOTS_HOME ← Engine code, generic tools, scripts Layer 2 (Modules): $KBOTS_MODULES ← Domain-specific tools/skills (per-module subdirs) Layer 3 (Overlay): $KBOTS_OVERLAY ← This deployment's config, agents, data ``` | Layer | Contains | Git workflow | Env var | |-------|----------|-------------|---------| | **Core** | `src/`, generic tools, skills, scripts, systemd templates | Branch + PR + cross-review | — | | **Layer 2** | Domain tools/skills in per-module dirs (e.g. `crm/`, `analytics/`) | Self-merge OK | `KBOTS_MODULES` | | **Overlay** | `config/`, `agents/`, `systemd/`, `data/`, agent identities | Direct push | `KBOTS_OVERLAY` | **What goes where:** - Core → engine code and generic tools - Layer 2 → domain tools (custom integrations, business logic) - Overlay → `config.yaml`, `agents.yaml`, `team.json`, agent AGENTS.md files, service units - **Never in Core or Layer 2** → personal names, Discord IDs, API keys, company data **Setup:** During Step 2, `setup.py` builds the overlay directory on its own and drops every piece of generated config into it. Systemd service units get the `KBOTS_OVERLAY` env var baked in, which is how the running process locates its config. **Discovery:** At startup the engine walks the Layer 2 module directories; any subdirectory containing a `tools/` or `skills/` folder is picked up automatically. Which modules are active is chosen in `setup.py` Step 4, which also assembles the `KBOTS_MODULES` path. ### Remote Neutralisation For anyone who isn't a maintainer, running `setup.py` after cloning Core rewires git: the `origin` remote becomes `upstream` and its push URL is disabled. With no pushable remote left, neither an agent nor a user can accidentally publish deployment data to the public Core repo. ``` origin → upstream (fetch: github.com/karvidsson/kbots, push: blocked) origin → (unset — nothing to push to by default) ``` Pulling updates keeps working via `git pull upstream main`. A maintainer who needs to push again can undo the neutralisation: ```bash git remote rename upstream origin git remote set-url --push origin ``` ### Deployment Patterns There are two ways to lay out a kbots deployment, and both keep Core untouched. What separates them is where domain-specific tools go: into a dedicated Layer 2 repo, or straight into the overlay next to your config. #### Two-Layer: Core + Overlay This is the minimal setup: the engine lives in one repo, and a second repo (or plain directory) holds everything unique to the install — config, agents, custom tools and skills, systemd units, and data. ``` Layer 1 (Core): /opt/kbots ← git clone of karvidsson/kbots (or your fork) Layer 3 (Overlay): /opt/kbots-myproject ← your private repo with config + agents + tools ``` Choose this pattern when: - You run a **single deployment** — one server, one roster of agents - Your custom tools serve only this install and nothing else will reuse them - You'd rather keep the setup as lean as possible An overlay directory looks like this: ``` my-overlay/ ├── config/ │ ├── config.yaml # Main config (connectors, security, defaults) │ ├── agents.yaml # Agent definitions and routing │ ├── team.json # Team roster (people + bots) │ ├── secrets.enc # Fernet vault (gitignored) │ └── secrets.salt # Vault salt (gitignored) ├── agents/ │ ├── main/ # Agent project directory │ │ ├── AGENTS.md # Agent identity (CLAUDE.md stub imports it) │ │ ├── codex/ # Per-agent knowledge base (own _index.md) │ │ ├── .mcp.json # MCP server config (generated by setup.py) │ │ └── .claude/ │ │ └── settings.json │ └── ops/ # Optional second agent ├── tools/ # Custom tools (auto-discovered) │ └── my_integration.py ├── skills/ # Custom skills (auto-discovered) │ └── my_workflow.yaml ├── systemd/ # Generated service/timer units ├── codex/ # Shared business knowledge base ├── scripts/ # Deployment-specific scripts ├── data/ # Runtime data (gitignored) └── tmp/ # Agent-generated files (gitignored) ├── media/ ├── docs/ └── scratch/ ``` You don't build this tree by hand — `setup.py` generates it. Point `KBOTS_OVERLAY` at it and setup is complete; `KBOTS_MODULES` stays unset. #### Three-Layer: Core + Modules + Overlay Meant for organisations operating **multiple deployments** that want common domain tools while each install keeps its own config, agents, and data. ``` Layer 1 (Core): /opt/kbots ← Engine (shared across all deployments) Layer 2 (Modules): /opt/kbots-modules ← Domain tools/skills (shared across deployments) Layer 3 (Overlay): /opt/kbots-deploy-a ← Deployment A config + agents /opt/kbots-deploy-b ← Deployment B config + agents (different server) ``` Reach for this pattern when: - Several **deployments consume one shared set** of custom tools - Each install carries its own agent configuration, team roster, or security settings - Shared tools should be maintainable centrally, with no per-deployment edits Layer 2 is expressed as a colon-separated list of module directories, where every module contributes a `tools/` and/or `skills/` subdirectory of its own: ``` kbots-modules/ ├── crm/ │ ├── tools/ │ │ └── salesforce.py │ └── skills/ │ └── pipeline_report.yaml ├── analytics/ │ └── tools/ │ └── bigquery.py └── ops/ ├── tools/ │ └── system_audit.py └── skills/ └── system_audit.yaml ``` Module selection happens per deployment in `setup.py` Step 4 — one install could enable `crm` + `analytics` while another runs with just `ops`. Whatever gets chosen ends up encoded in the `KBOTS_MODULES` env var: ```bash KBOTS_MODULES=/opt/kbots-modules/crm:/opt/kbots-modules/analytics ``` #### Discovery Order Discovery of tools and skills proceeds layer by layer, and a name defined in more than one layer resolves to the later one — the overlay beats modules, and modules beat core: ``` Core tools/skills → Layer 2 modules → Overlay tools/skills → Agent-specific skills ``` `resolve_config_file()` applies the matching precedence when looking up config files: overlay → modules → core. ### Keeping Core Clean Core is the engine everyone shares — public, redistributable, and the upstream that all deployments track. Anything deployment-specific that slips in gets propagated to every install that pulls. **Rules:** 1. **No personal data.** Core must stay free of names, Discord IDs, email addresses, company names, and API keys — no exceptions, whether the location is a config file, a code comment, or a commit message. 2. **No deployment config.** Live `config.yaml`, `agents.yaml`, and `team.json` files belong to the overlay; the only versions shipped in Core are the `.example` templates. 3. **No domain tools.** Business-specific tooling — a CRM integration, a custom ETL job, a proprietary API client — lives in Layer 2 or the overlay. What Core ships are general-purpose utilities that any deployment can use. 4. **No agent identities.** An AGENTS.md that defines a named agent — its personality, permissions, and standing instructions — is overlay material; Core carries nothing beyond example templates. 5. **No hardcoded paths.** Every path inside Core goes through `PROJECT_ROOT` (Python), `$KBOTS_HOME` (shell), or an env var. Where the systemd templates say `/opt/kbots`, that is a placeholder token that `setup.py` swaps for the real install path during setup. 6. **Branch workflow.** A pre-commit hook refuses commits made directly on `main`; work lands via feature branches and reviewed PRs instead, shielding every downstream deployment from untested changes. **Why this matters:** A clean Core is what makes `git pull` painless — updates arrive without a single merge conflict, and your agents, config, and tools stay exactly as they were. Were deployment data mixed into Core, each pull would turn into conflict resolution, and sooner or later you'd give up on pulling at all — falling behind on security fixes and features. Most of this discipline is enforced by the `setup.py` wizard itself: non-maintainers get their git remote renamed so pushes are blocked, generated config is written only to the overlay, and `.gitignore` rules cover runtime data. Polluting Core by accident takes real effort under this design. --- ## Key Architectural Decisions 1. **Claude Code CLI is a black box** — tool execution happens in the MCP subprocess rather than the main process, which leaves the `_dispatch_tools()` loop in agent_manager as dead code whenever the Claude Code provider is in use. 2. **MCP server IS the middleware layer** — the MCP tool handlers, not agent_manager, are where rate limiting, HITL, and auditing take place. 3. **All paths must be absolute** — the `cwd` field in `.mcp.json` is ignored by Claude Code, so MCP tools anchor every file reference with `Path(__file__).resolve().parent.parent`. 4. **`KBOTS_PROFILE` env var** — the single switch selecting test or production config at every link of the chain (main.py → Claude Code → MCP server). 5. **HITL always uses the primary bot token** — no matter which agent is running or which profile is loaded. 6. **One MCP server per Claude Code session** — every agent gets its own MCP server subprocess, and each of those servers loads the vault independently. --- ## Directory Layout ``` kbots/ ├── src/ │ ├── core/ │ │ ├── agent_manager.py — sessions, context injection, LLM dispatch │ │ ├── access_control.py — three-layer permission system (sender tier × agent tier) │ │ ├── registry.py — auto-discovery of all modules │ │ ├── router.py — message routing: connector/channel/category → agent │ │ ├── tools.py — @tool decorator, schema from type hints │ │ ├── skills.py — YAML skill loader │ │ ├── storage.py — SQLite (sessions, messages, tool logs) │ │ ├── hitl.py — HITL approval gates (connector-side, for main process) │ │ ├── rate_limiter.py — per-tool per-agent sliding window │ │ ├── audit.py — append-only JSONL audit log │ │ ├── content_safety.py — behavioral monitoring │ │ ├── digest.py — hot-reload file watcher │ │ └── base.py — interfaces, dataclasses │ ├── connectors/ │ │ └── discord.py — multi-bot, typing, slash commands, loop detection, dedup │ ├── llm/ │ │ └── claude_code.py — Claude Code CLI (Pro/Max sub or API billing) │ ├── tools/ — @tool decorated functions (auto-discovered) │ │ ├── memory.py — store, search, semantic_search, forget │ │ ├── team.py — list, get, add, update, remove + user context │ │ ├── web_search.py — Tavily search │ │ ├── audio.py — transcribe_audio (Groq Whisper) │ │ ├── discord_tools.py — read_channel, read_message, search, send_file │ │ ├── video.py — video_frames, video_clip │ │ ├── tmux.py — list, send, read, new │ │ ├── compress.py — compress_context, decompress_context │ │ ├── ingest.py — create_skill, read_url, browse_url, install_mcp, list_capabilities │ │ └── builtin.py — send_message, ask_agent, send_to_agent │ ├── lib/ │ │ └── compressor.py — rule-based context compression (no LLM calls) │ ├── vault/ │ │ └── fernet.py — Fernet encrypted vault │ ├── auth/ │ │ └── oauth2.py — OAuth2 refresh (Google, etc.) │ ├── mcp_server.py — FastMCP server with middleware chain │ └── main.py — entry point, --profile support ├── agents/ │ └── rescue/ — example agent template (AGENTS.md.example) ├── config/ │ ├── config.yaml.example — example production config │ ├── agents.yaml.example — example agent definitions │ ├── team.json.example — example team roster │ ├── kbots.service — systemd unit template │ ├── kbots-rescue.service — systemd unit template (unsandboxed) │ └── timers/ — systemd timer+service files for scheduled tasks ├── scripts/ │ ├── test-tools.py — e2e tests across tool categories │ ├── health-audit.sh — full system audit (timer + on-demand via /system-audit) │ ├── monitor-integrity.sh — file integrity SHA256 │ ├── regen-memory-context.sh — memory stats snapshot │ ├── memory-decay.sh — memory decay/archive/purge │ ├── compress-context.sh — batch context file compression │ ├── install-timers.sh — install all systemd timers (legacy) │ ├── install-systemd.sh — symlink units, daemon-reload, enable timers │ ├── google-reauth.py — Google OAuth2 re-authentication helper │ └── lib-alert.sh — shared Discord alert helper ├── skills/ — YAML skill definitions (auto-discovered) ├── extras/ — opt-in integrations (google, trello, stocks…) — NOT auto-discovered; install via cp into the overlay (see extras/README.md) ├── data/ — SQLite DBs, audit logs (gitignored) ├── vault-manage.py — interactive vault secret manager ├── setup.py — interactive setup wizard (single entry point) └── setup.sh — deprecated stub (redirects to setup.py) ``` --- ## Agents Agents are declared in `config/agents.yaml`, and each one may carry its own bot account, its own tool access list, and its own channel/category routing. Some example agent archetypes: | Type | Built-in Tools | MCP Tools | Use Case | |------|---------------|-----------|----------| | **Coordinator** | Blocked (Edit, Write, Bash, MultiEdit) | All | Runs business operations using nothing but MCP tools | | **Privileged** | Full access | All | Dev/ops work: administration, code edits, deploys | | **Assistant** | Blocked | Restricted whitelist | Narrow role with access trimmed to what the job requires | ### Access Control (Three-Layer) Implemented in `src/core/access_control.py`, with its data drawn from `config/team.json`: | Layer | What it controls | Where it lives | |-------|-----------------|----------------| | **1. Can they talk?** | Sender tier × agent tier decides whether the message is handled or ignored | `access_control.py` | | **2. What tools?** | Per-message `--disallowedTools` derived from the sender's tier | `access_control.py` → `agent_manager.py` | | **3. Agent ceiling** | Fixed per-agent config (tools list, disallow_builtins) | `agents.yaml` | **People tiers:** owner (everything) → admin (safe tools plus HITL) → staff (safe tools, assistant agents only) → unknown (no access) **Agent tiers:** privileged (CLI and every MCP tool) → coordinator (all MCP, no CLI) → assistant (safe MCP subset, no CLI) ### Agent Config Every agent points at a `project_dir` holding three things: AGENTS.md for identity and tool documentation (plus a CLAUDE.md stub importing it for Claude Code), `.mcp.json` for the MCP server config, and `.claude/settings.json` for permissions, env, and allowed tools. ```yaml # config/agents.yaml — example agents: primary: display_name: "MyAgent" project_dir: ./agents/primary tools: all # MCP tools ("all" or explicit list) disallow_builtins: # Block Claude Code built-in tools - Edit - Write - Bash - MultiEdit routing: discord: account: default channels: [] # All channels (wildcard) ``` ### Routing When a message arrives, routing decides which agent picks it up. Every connector defines its own namespace of routing keys (`routing.discord`, `routing.http`, and so on). **Discord routing — scope vs filters:** Two mechanisms combine here: **scope** picks the channels an agent listens in, and **filters** layer extra constraints on top of that. *Scope* — evaluated by priority, stopping at the first match: | Priority | Config Key | Effect | |----------|-----------|--------| | 1 | `channels: [id, ...]` | Matches a channel ID exactly; wins over everything else | | 2 | `categories: [id, ...]` | Every channel under a given Discord category | | 3 | `channels: []` (empty) | Wildcard: listen everywhere | | 4 | DM fallback (implicit) | DMs go to whichever agents are bound to that bot | *Filters* — checked regardless of how scope matched: | Filter | Config Key | Effect | |--------|-----------|--------| | **Bot account** | `account` | Routing namespace; every bot routes on its own. Required. | | **Guild/Server** | `guilds: [id, ...]` | Limits matching to the listed servers; an empty list means no limit. | | **Mentions** | `mentions: true/false` | If enabled, the agent answers only DMs and messages that @mention it. | | **Users** | `users: [id, ...]` | Only respond to these sender user IDs; an empty list means everyone the team tiers allow. When set, messages carrying no sender ID (system posts) are dropped too. | Overlapping routing rules are allowed: when several agents match one message, every one of them gets a copy. **Examples:** ```yaml # Agent responds to all channels on bot "main", only when @mentioned routing: discord: account: main channels: [] mentions: true # Agent scoped to a specific Discord category routing: discord: account: assistant categories: ["1234567890"] mentions: false # Agent locked to one channel in one server routing: discord: account: ops channels: ["9876543210"] guilds: ["1111222233"] mentions: true ``` **LLM defaults** may carry an `mcp_config` entry, which attaches external MCP servers to every agent at once: ```yaml defaults: llm: provider: claude_code model: sonnet mcp_config: /path/to/mcp.yaml # Optional: external MCP servers ``` --- ## Tools A tool is nothing more than an async Python function in `src/tools/` carrying the `@tool` decorator. Discovery happens automatically at startup: adding a file is all it takes to make it available. Tool packs that ship with the engine: | Category | Tools | |----------|-------| | **Memory** | store, search, semantic_search, forget | | **Team** | list, get, add, update, remove | | **Google Workspace** | Gmail (search, read, send), Calendar (list, create), Drive (search, list, download, create, upload, delete, share) | | **Trello** | boards, lists, cards, create_card, activity | | **Media** | gemini_analyze_image, gemini_analyze_video, gemini_generate_image, transcribe_audio, video_frames, video_clip | | **Discord** | read_channel_history, read_message, search_channel_history, send_discord_file | | **Cloudflare** | zones, dns_list, dns_update | | **Notion** | search, read, create | | **Web** | web_search (Tavily) | | **Compression** | compress_context, decompress_context | | **System** | create_skill, read_url, browse_url, browser, install_mcp, list_capabilities | | **Tmux** | list, send, read, new | | **Android** | android_device (screenshot, tap, swipe, type, key, launch, open_url, install — emulator or real phone via ADB, see [docs/ANDROID.md](docs/ANDROID.md)) | | **Inter-agent** | send_message, ask_agent, send_to_agent | | **Design** | render_diagram (Mermaid, offline via vendored mermaid.js), render_svg, render_html, html_to_pdf, create_slides | | **Process mapping** | process_model_save, process_model_load, process_model_gaps, process_render, process_publish — see below | *Tools flagged for HITL in config pause until a human approves them with a Discord reaction.* ### Process Mapping Business processes and Wardley maps are captured as a **structured model first, diagram second**. The agent fills a JSON model (`kind: process` — actors, steps, decisions, handoffs, systems, metrics, exceptions; or `kind: wardley` — anchors, components with visibility/evolution and a stage rationale, links, inertia, movement) and the engine does the deterministic work: - `process_model_save` validates (dangling edges, unlabeled decisions, coordinates outside 0..1, dependencies pointing the wrong way …), **patch-merges** into `/processes//model.json` so an interview can add a little each turn, and returns the **ranked next questions** — picked from a method question bank (SIPOC, RACI/swimlanes, value stream mapping, BPMN discovery, Lean wastes, 5 Whys, TOC, service blueprint, event storming, Wardley's canvas and evolution cheat sheet — `src/lib/process_questions.py`) according to which model fields are empty, hedged ("usually", "?") or contradictory. That is how the agent "asks the right questions": never a generic checklist, always the gap. - `process_model_gaps` re-ranks on demand, optionally through a lens (`sipoc`, `raci`, `vsm`, `bpmn`, `wastes`, `wardley`, `blueprint`, `events`) — the coach mode for a live workshop. - `process_render` emits diagram text (`src/lib/process_model.py`) and renders it locally: Mermaid `flowchart` / `swimlane-beta` (lane per actor, with a flowchart-with-subgraphs fallback) / `sequenceDiagram` (handoffs) / `journey` through `render_diagram`, and Wardley maps through a self-contained SVG emitter (`src/lib/wardley_svg.py`, no website or CDN involved) plus an `.owm` text twin. Results go to Discord with `send_discord_file`. - `process_publish` promotes a finished process into the **codex** (`/codex/processes/.md` + PNG, registered in `_index.md`) so every agent sees it in its startup `` and can use it as business knowledge. It is a deliberate, user-confirmed step. Slash commands, all under the `/process-` prefix: `/process-map` (notes, image/whiteboard photo, interview, update, publish), `/process-wardley`, `/process-questions` (`skills/process_*.yaml`). Images reach the model through `download_file` + the CLI's `Read` (vision); the skills instruct inventory-first extraction and put every unreadable label or unverifiable arrow into `open_questions` rather than guessing. To wire in your own integration, place a Python file with a `@tool` decorated function in `src/tools/`. ### Tool Development Guide 1. **Create the file** — `src/tools/my_tool.py`, or `/tools/my_tool.py` when it's a domain tool 2. **Decorate with `@tool`** — supply a name and description, plus `hitl=True` if the tool should be gated 3. **Type-hint parameters** — annotations (str, int, bool, Optional, list) are all the schema generator needs 4. **Accept `ctx: ToolContext`** — your handle to `ctx.vault` (secrets), `ctx.memory` (memory backend), `ctx.project_dir`, `ctx.agent_id` / `ctx.channel_id` (see `src/core/base.py`) 5. **Return a string** — whatever comes back is what the LLM sees as the result ```python from src.core.base import ToolContext from src.core.tools import tool @tool(name="my_tool", description="Does something useful") async def my_tool(ctx: ToolContext, query: str, limit: int = 10) -> str: api_key = ctx.vault.get("my-api-key") # ... do work ... return f"Found {limit} results for {query}" ``` On the next startup the tool is found automatically — nothing to import, no registry entry to touch — and every agent can reach it through MCP right away. **Testing:** `uv run python scripts/test-tools.py --tool my_tool` **HITL gating:** When a tool must always ask for human approval no matter how a deployment is configured, set `hitl=True` on the decorator: `@tool(name="dangerous_tool", description="...", hitl=True)` ### MCP Configuration Claude Code CLI spawns a dedicated MCP server subprocess (`src/mcp_server.py`) for each agent. Two files govern how those servers are configured: **Per-agent `.mcp.json`** — sits in the agent's project directory and wires up the kbots tool server: ```json { "mcpServers": { "kbots-tools": { "command": "/.venv/bin/python3", "args": ["-m", "src.mcp_server"], "cwd": "", "env": { "KBOTS_PROFILE": "${KBOTS_PROFILE:-}", "KBOTS_PROJECT_DIR": "/agents/main", "KBOTS_OVERLAY": "" } } } } ``` > **Note:** You never author this file yourself — `setup.py` writes `.mcp.json` with real absolute paths filled in at setup time. `` and `` above simply mark where those substitutions land. **Required env vars:** The MCP server derives the agent ID from `KBOTS_PROJECT_DIR`, and tools that consult overlay config (the health audit, among others) rely on `KBOTS_OVERLAY`. Both are filled in automatically by `setup.py`. **External MCP servers** — to bring in further MCP servers (third-party tools, other systems), declare them in `config/mcp.yaml`: ```yaml servers: kbots-tools: transport: stdio command: .venv/bin/python3 # resolved to absolute path by setup.py args: ["-m", "src.mcp_server"] # Example: connect an external MCP server my-server: transport: stdio command: npx args: ["-y", "@my-org/mcp-server"] ``` Setting `mcp_config` under `defaults.llm` exposes the external servers to every agent. From the LLM's perspective their tools are indistinguishable from built-in ones — they surface as native tools. ### Inter-Agent Communication Agents communicate through `ask_agent` and `send_to_agent`. Both work from any LLM backend, including the Claude Code CLI: tools run inside the MCP server (a subprocess with no handle on the AgentManager), so the calls travel over the **loopback internal API** — an HTTP server in the main process, bound to 127.0.0.1, authenticated with a per-boot bearer token passed via env. A depth guard (`KBOTS_INTER_AGENT_DEPTH`, max 3) is enforced on both sides to prevent A→B→A loops. **`ask_agent(agent_name, question)` — synchronous Q&A.** Runs the target's turn in a private side-session (`internal::`) and returns the answer to the caller. The exchange is not posted anywhere; the caller is responsible for surfacing whatever matters. Concurrent asks to the same target serialize on a per-session lock (shared CLI session — concurrent `--resume` collides). **`send_to_agent(agent_name, message)` — fire-and-forget, delivered visibly.** The message is routed into the target's **home channel**: the incoming message is posted there (`📨 **sender → target:** …`), the target's turn runs in its normal channel session — full conversation context, same queueing/locking as a user message — and the reply is posted to that channel. Nothing is returned to the sender; the tool result says so explicitly. The target's turn gets an `` context block naming the sender, with a `NO_REPLY` abstain path so pure acknowledgements don't spam the channel. The home channel resolves in order: an explicit `home_channel` in the agent's routing block → the first entry of a concrete `channels` list → the agent's most recently active real conversation channel (for wildcard-routed agents). If nothing resolves (e.g. a connector-less agent), the send falls back to the invisible internal side-session — the pre-visible-delivery behavior — and the tool result warns that the outcome will not surface anywhere. Why visible delivery: a hidden side-session turn whose response nobody reads is how sends get "lost" — the target's channel self has no trace of the exchange (different CLI session), the owner never sees the outcome, and the sender's "dispatched" reads as delivered-and-seen. Routing through the home channel puts the request, the context, and the reply in one place. Bot-to-bot @mentions via `send_message` do **not** reliably invoke the target agent and must not be used as a messaging bus — use the tools above. --- ## Services ### Dual-Instance Pattern To split privileges, kbots can run as a pair of instances off one codebase: | Instance | Service | Profile | Sandbox | Purpose | |----------|---------|---------|---------|---------| | **Main** | `kbots.service` | default | Sandboxed — `ProtectSystem=strict`, `NoNewPrivileges=true`, capabilities dropped | The agents users talk to: business ops, assistants | | **Ops** | `kbots-rescue.service` | `rescue` | No sandbox — full filesystem plus sudo | One privileged agent handling dev/ops, deploys, and debugging | The two are the same program (`uv run python -m src.main`) told apart only by `--profile`. SQLite databases (WAL mode) and the Fernet vault are common to both, while lock files are per-instance. On the main instance, `disallow_builtins` shuts off the built-in tools (Edit, Write, Bash), leaving agents with MCP tools alone. The ops instance opens everything up — and compensates by letting access control confine its use to the owner. The ops-instance step of `setup.py` optionally provisions the ops instance, producing its own `agents.rescue.yaml` and a bot account reserved for it. The systemd step renders the matching unit (`config/kbots-rescue.service`) with the same machinery as the main unit — HOME resolved from `User=`, `KBOTS_OVERLAY` injected — and installs and starts it alongside `kbots.service`. ### Systemd ```bash # Main service systemctl start kbots.service systemctl status kbots.service journalctl -u kbots -f # Ops instance systemctl start kbots-rescue.service ``` ### Sandboxing (Main Instance) Systemd sandboxing is enabled on the main service unit: - `ProtectSystem=strict` — everything on disk is read-only apart from the listed `ReadWritePaths` - `NoNewPrivileges=true` — escalating privileges is impossible - `CapabilityBoundingSet=` — the capability set is emptied entirely - `PrivateDevices=true` — physical devices are hidden from the process - `RestrictNamespaces=true` — creating namespaces is forbidden - `SystemCallFilter=~@mount @reboot @swap @debug @obsolete` — risky syscall groups are denied - `ReadWritePaths` — write access covers only the overlay dirs, data, tmp, and the Claude Code cache None of these restrictions apply to the ops instance — by design. ### File Ownership Everything under the install directory belongs to `kbots:kbots`, the service user. A root-owned file in the tree is a time bomb: the service can read it but never write it, and a single root-owned file inside `.venv/` is enough to make `uv sync` / `uv run` fail. If you edit as root — say from a Claude Code session running with root privileges — restore ownership with chown after each save: ```bash # Check for misowned files find "$KBOTS_HOME" -not -user kbots 2>/dev/null # Fix sudo chown -R kbots:kbots ``` The service user's **`~/.claude.json` and `~/.claude/`** are part of the same rule and matter even more: a root-owned `~/.claude.json` breaks headless workspace trust and silently denies **every tool for every agent**. Ownership problems on these paths are caught at boot by the preflight and at runtime by [Permission Watch](#permission-watch); the full failure catalog, per-platform setup (Linux / macOS / Windows-WSL2), and prevention hooks are in [docs/PERMISSIONS.md](docs/PERMISSIONS.md). --- ## Security | Layer | Implementation | |-------|---------------| | **Access control** | Sender tier × agent tier evaluated in three layers, with per-message tool filtering and a static agent ceiling | | **Credentials** | Fernet vault (`config/secrets.enc`), per-instance random salt, PBKDF2 key derivation | | **HITL** | Gated tool list set in config; approval via Discord reaction; timeouts fail closed by default | | **Content safety** | Sanitize (log-only), then injection scoring, then behavioral monitoring — a three-stage pipeline | | **Security alerts** | Content-safety hits and behavioral anomalies alert a configured Discord channel in real time | | **Permission watch** | Runtime rights-failure detection (broken workspace trust, tool denials, wrong ownership) escalated to a main agent with exact fixes — see [docs/PERMISSIONS.md](docs/PERMISSIONS.md) | | **Rate limiting** | Per-tool sliding window (enforce mode), record-before-execute (TOCTOU-safe) | | **Bot-to-bot loop detection** | Rate/repeat guard (5 exchanges / 60s, repeated acks) + per-channel chain breaker (6 consecutive bot turns, no human → quiet until a human posts) + `NO_REPLY` abstain sentinel | | **Duplicate message suppression** | Per-channel dedup: identical content posted to a channel twice inside 120s is discarded | | **Audit** | Every tool call, HITL decision, and auth event lands in a JSONL log | | **SSRF** | App-layer URL validation: scheme whitelist, RFC1918/localhost/link-local block, DNS rebinding protection | | **Path traversal** | Any tool writing to a user-controlled path runs `validate_file_path()` first | | **SQL injection** | Parameterized queries everywhere | | **Env isolation** | Only allowlisted env vars reach the Claude Code subprocess, so secrets can't leak | ### Content Safety Pipeline `src/core/content_safety.py` implements a pipeline of three stages, applied to the output of **web-facing tools** — the ones that pull in external content where adversarial payloads might hide. The pipeline executes in `src/mcp_server.py` once a tool has finished. **Which tools count as web-facing:** `web_search`, `browse_url`, `read_url`, `browser`, `gmail_read`, `gmail_search`, `download_file` — the list lives in `src/core/alerts.py:WEB_FACING_TOOLS`. #### Stage 1: Sanitization `sanitize(text)` — removes material that has no business entering the LLM context: - Invisible Unicode: zero-width joiners, bidi overrides, BOM - `