-
Notifications
You must be signed in to change notification settings - Fork 8
Protected Installation
A protected installation deploys Kai with hardened directory permissions, separating read-only source code, writable runtime data, and root-owned secrets across three locations. This is the recommended setup for always-on deployments where the bot runs as a system service.
In development mode (make run), everything lives under the project directory and runs as your user. That's fine for hacking, but for a persistent service you want stronger isolation:
- Source code is read-only - owned by root, the service user can't modify it
-
Secrets are root-owned -
.envequivalent lives at/etc/kai/envwith mode 0600; the service user reads it via targeted sudoers rules - Runtime data is separated - database, logs, and file exchange live in their own directory
-
The inner Claude process can't access secrets - it runs as the service user (or optionally a separate user) with no access to
/etc/kai/
The installer handles both macOS (launchd) and Linux (systemd).
- Python 3.13+ on the host
-
Claude Code CLI installed for the service user (
~/.local/bin/claude) - A Telegram bot token and your user ID (see Getting Started)
-
A dedicated OS user for the service (e.g.,
kai) - optional but recommended - macOS or Linux - the installer detects and handles both
The installer uses a two-step process that separates privilege levels:
python -m kai install config # no sudo - interactive Q&A, writes install.conf
sudo python -m kai install apply # root - reads install.conf, creates the layout
python -m kai install status # no sudo - reports current state
install.conf bridges the two steps: config writes it, apply reads it. It's a JSON file stored in the project root with restricted permissions (mode 0600) since it contains secrets.
Run from the project directory as your normal user:
python -m kai install configThis walks through an interactive Q&A. If install.conf already exists, its values are used as defaults so re-running only asks about changes.
Installation paths:
| Field | Default | Description |
|---|---|---|
| Install location | /opt/kai |
Where source code and venv go (root-owned) |
| Data directory | /var/lib/kai |
Where database, logs, and files go (service-user-owned) |
| Service user | kai |
The OS user the service runs as |
| Platform | auto-detected |
darwin or linux
|
Telegram:
| Field | Default | Description |
|---|---|---|
| Bot token | (required) | From @BotFather |
| Allowed user IDs | (required*) | Comma-separated Telegram user IDs (*not required when users.yaml exists) |
| Transport | polling |
polling or webhook
|
| Webhook URL | Required if transport is webhook
|
|
| Webhook secret | Optional Telegram webhook verification secret |
Claude:
| Field | Default | Description |
|---|---|---|
| Model | sonnet |
haiku, sonnet, or opus
|
| Timeout | 120 |
Seconds before Claude times out |
| Budget | 10.0 |
Max USD per session |
Webhook server:
| Field | Default | Description |
|---|---|---|
| Port | 8080 |
Webhook/API server port |
| Secret | auto-generated | Required for all /api/* and /webhook/* endpoints |
Workspaces:
| Field | Default | Description |
|---|---|---|
| Workspace base | Base directory for workspace name resolution (e.g., ~/Projects) |
|
| Allowed workspaces | Comma-separated absolute paths for pinned workspaces |
Optional features:
| Field | Default | Description |
|---|---|---|
| Voice transcription | false |
Enable whisper.cpp STT |
| Text-to-speech | false |
Enable Piper TTS |
| Claude subprocess user | Optional separate OS user for process isolation (see User separation below) | |
| Perplexity API key | For the external services proxy |
After answering all prompts, the config is written to install.conf:
Configuration written to /path/to/kai/install.conf
Review the file, then run: sudo python -m kai install apply
Run as root:
sudo python -m kai install applyThis reads install.conf and performs the installation in order:
- Stops the service if it's already running
- Creates directories with correct ownership and permissions (see Directory layout)
-
Copies source to the install location (clean copy, excludes
__pycache__,.pyc,.git,.venv,.env) -
Creates or updates the venv with Python 3.13+; on update, only rebuilds if
pyproject.tomlchanged (checksum comparison) - Copies model files (whisper, Piper) if present in the source directory
-
Writes secrets to
/etc/kai/env(mode 0600) and copiesservices.yamlto/etc/kai/if present -
Configures sudoers at
/etc/sudoers.d/kai(validated withvisudo -cfbefore writing) - Migrates runtime data from dev layout if this is the first protected install (database and logs; never overwrites existing files)
- Generates and installs the service definition (launchd plist or systemd unit)
- Starts the service
Preview everything without making changes:
sudo python -m kai install apply --dry-runEvery action is printed with a [DRY RUN] prefix. Always use this on your first attempt.
Run as your normal user:
python -m kai install statusThis reports the current installation state without requiring sudo:
Kai Installation Status
==============================
Installation: /opt/kai (exists, root:root)
Data: /var/lib/kai (exists, kai:kai)
Secrets: /etc/kai/env (exists, root:root)
Services: /etc/kai/services.yaml (exists, root:root)
Sudoers: /etc/sudoers.d/kai (exists, root:root)
Service: com.syrinx.kai (loaded)
Version: 0.1.0
It also checks workspace path traversal - if the service user can't traverse any parent directory of a configured workspace, you'll see a warning with the specific fix (e.g., chmod o+x /Users).
A protected installation creates this structure:
/opt/kai/ root:root 755 Read-only install tree
src/ root:root 755 Python source
venv/ root:root 755 Virtual environment
home/ kai:kai 755 Inner Claude home workspace
pyproject.toml root:root 644 Package metadata
run.sh root:root 755 Launcher script (macOS only)
models/ root:root 755 Whisper/Piper models (if present)
.pyproject.sha256 root:root 644 Venv update detection checksum
/var/lib/kai/ kai:kai 755 Runtime data
kai.db kai:kai 644 SQLite database
logs/ kai:kai 755 Log files (daily rotation)
files/ kai:kai 755 File exchange directory
<chat_id>/ kai:kai 755 Per-user upload subdirectories
history/ kai:kai 755 Conversation history
<chat_id>/ kai:kai 755 Per-user history (JSONL, one file per day)
memory/ kai:kai 755 Persistent memory
MEMORY.md kai:kai 644 Home memory file
.responding/ kai:kai 755 Crash recovery flags (per-user)
/etc/kai/ root:root 755 Secrets
env root:root 600 Environment variables
services.yaml root:root 600 External service API keys
users.yaml root:root 600 Per-user config (if present)
workspaces.yaml root:root 600 Per-workspace config (if present)
totp.secret root:root 600 TOTP secret (if configured)
totp.attempts root:root 600 TOTP attempt tracking
The key principle: the service user (kai) can write to /var/lib/kai/ and /opt/kai/home/, but cannot read /etc/kai/env directly. At startup, config.py reads secrets via sudo cat /etc/kai/env using the NOPASSWD sudoers rules the installer configured.
Runtime data (history, memory, files) lives entirely in /var/lib/kai/, never inside the install tree. This means make install / install apply can cleanly re-copy source without destroying conversation history or memory.
By default, both the bot process and the inner Claude Code process run as the same service user. Process isolation adds a second layer: the inner Claude process runs as a different OS user entirely.
There are two ways to configure this:
-
CLAUDE_USER(env var) - global setting, applies to all subprocesses. Best for single-user deployments. -
os_user(per-user inusers.yaml) - each user's subprocess runs as their own OS account. Best for multi-user deployments. Takes precedence overCLAUDE_USER.
See Multi-User Setup for the full multi-user configuration.
When process isolation is configured:
- The bot starts Claude with
sudo -u <os_user> claude ...instead of justclaude ... - Claude runs in a new process session (
start_new_session=True) so signals from the bot don't leak to it - The sudoers rules include rules allowing the service user to run the
claudebinary as each configured OS user
| Can access | Cannot access |
|---|---|
| Its own home directory |
/etc/kai/ (secrets) |
| The workspace directory | Kai's database (/var/lib/kai/kai.db) |
| Tools on PATH | Kai's source code (root-owned) |
- Create the OS user(s) (e.g.,
claude, or per-user accounts likealice,bob) - For single-user: set
CLAUDE_USER=claudeduringinstall config. For multi-user: setos_userper user inusers.yaml. - Run
sudo install apply- it generates the sudoers rules automatically - Make sure the Claude Code CLI is installed and accessible to each OS user (the sudoers rules reference its path)
This is optional. For single-user local deployments, the default (no isolation) is fine. User separation is most valuable when you want defense in depth - even if Claude is compromised, it can't read bot secrets or modify the database. In multi-user deployments, it also prevents one user's subprocess from accessing another user's files.
Re-run the same two steps:
-
python -m kai install config- only if config values changed (existing values are shown as defaults) -
sudo python -m kai install apply- handles updates automatically:- Source is re-copied (clean copy every time)
- Venv is only rebuilt if
pyproject.tomlchanged - Secrets are re-written
- Service is stopped before changes and started after
- Database is never wiped on update
| Aspect | macOS | Linux |
|---|---|---|
| Service type | LaunchDaemon plist | systemd unit |
| Service location | /Library/LaunchDaemons/com.syrinx.kai.plist |
/etc/systemd/system/kai.service |
| Launcher script | Yes (run.sh) - needed because Homebrew Python re-execs through Python.app, changing the PID |
No - systemd tracks the process directly |
| Start/stop | launchctl bootstrap/bootout system/com.syrinx.kai |
systemctl start/stop kai |
| Auto-restart |
KeepAlive in plist |
Restart=always in unit |
| Boot start |
RunAtLoad in plist |
WantedBy=multi-user.target |
| Directory layout | Same | Same |
| Sudoers rules | Same (binary paths auto-detected) | Same |
The launcher script on macOS deserves a note: Homebrew Python's framework binary fork-execs through Python.app, creating a grandchild process with a new PID. Launchd loses track of this. The run.sh wrapper stays as the parent process launchd tracks and forwards SIGTERM to the real Python process for graceful shutdown.
Service won't start
Check kai.log in the data directory (/var/lib/kai/logs/). Common causes: missing claude binary on PATH, Python version mismatch, or permission errors on the workspace directory.
"Permission denied" reading /etc/kai/env
The sudoers rules may be missing or incorrect. Verify with sudo visudo -cf /etc/sudoers.d/kai. Re-run sudo python -m kai install apply to regenerate them.
Claude binary not found
The claude CLI must be installed under the service user's ~/.local/bin/. The generated plist/unit includes this in PATH, but if you manually edited the service definition, check the PATH. The sudoers rule for CLAUDE_USER also references the binary path - re-run install apply if you reinstalled Claude Code.
"Python >= 3.13 required" during apply
The installer requires Python 3.13+. Install it and ensure it's the default python3 on your PATH, or install as python3.13 (the installer checks both).
Database not found after migration
The installer copies kai.db from the project root to /var/lib/kai/ on first apply. Existing files are never overwritten. If you see database errors, check that KAI_DATA_DIR is set correctly in the service environment.
Workspace traversal warnings
The installer and status command check that the service user can traverse every parent directory of configured workspace paths. If you see a warning like WARNING: /Users lacks execute permission for kai, follow the suggested fix (e.g., chmod o+x /Users).
Always dry-run first
Use sudo python -m kai install apply --dry-run to preview all changes before applying.