-
Notifications
You must be signed in to change notification settings - Fork 8
Multi User Setup
Kai can serve multiple Telegram users from a single instance, with full isolation between them. Each user gets their own Claude Code subprocess, conversation history, workspace state, scheduled jobs, and file storage.
Multi-user support is backward compatible. If you don't configure users.yaml, Kai works exactly as before with ALLOWED_USER_IDS.
Define users in users.yaml at the project root (or /etc/kai/users.yaml for protected installations):
users:
- telegram_id: 123456789
name: alice
role: admin
github: alice-dev
os_user: alice
home_workspace: /home/alice/workspace
max_budget: 15.0
- telegram_id: 987654321
name: bob
role: user
github: bobsmith
home_workspace: /home/bob/workspace
max_budget: 10.0Copy users.yaml.example from the repo to get started.
| Field | Required | Default | Description |
|---|---|---|---|
telegram_id |
Yes | Telegram user ID (find via @userinfobot) | |
name |
Yes | Display name for logs and notifications | |
role |
No | user |
admin or user (see Roles below) |
github |
No | GitHub username for webhook actor routing | |
os_user |
No | OS account for subprocess isolation (see Process isolation) | |
home_workspace |
No | global default | Per-user home workspace directory |
max_budget |
No | Ceiling in USD for the /budget command. CLAUDE_MAX_BUDGET_USD is the actual default applied to sessions; max_budget limits what the user can raise it to. |
When users.yaml exists, it replaces ALLOWED_USER_IDS entirely. If both are set, users.yaml wins and a warning is logged. If neither exists, Kai refuses to start (fail-closed).
Two roles exist: admin and user.
Admins receive notifications for unattributed external events - GitHub pushes by unknown actors, generic webhook payloads, and other events that can't be mapped to a specific user. At least one admin is recommended; if none are defined, external webhook notifications are dropped with a warning.
Regular users interact only through Telegram messages. They don't receive webhook notifications unless a GitHub event is specifically attributed to them via the github field.
When a GitHub webhook arrives (push, PR, issue comment), Kai checks the actor's GitHub username against the github field in users.yaml. If there's a match, the notification routes to that user's Telegram chat instead of going to all admins. This means:
- Alice pushes a branch - Alice gets the push notification, not the admin group
- An unknown contributor opens a PR - admins get notified
- Bob comments on an issue - Bob doesn't get a notification about his own comment (the webhook is silently handled)
All user state is namespaced by Telegram chat ID. There is no shared mutable state between users.
| Data | Isolation method |
|---|---|
| Conversation history | Per-user subdirectories: DATA_DIR/history/<chat_id>/
|
| Workspace selection | Settings keyed as workspace:{chat_id}
|
| Scheduled jobs | Jobs table includes chat_id column |
| File uploads | Saved to DATA_DIR/files/<chat_id>/
|
| Workspace history | Composite key (chat_id, path)
|
| Crash recovery flags | Per-user flag files in .responding/{chat_id}
|
| Session tracking | One row per user in sessions table |
Each user gets their own Claude Code subprocess, managed by a pool (pool.py).
Subprocesses are not spawned at startup. When a user sends their first message, pool.get(chat_id) creates a PersistentClaude instance with that user's configuration (os_user, home_workspace). The actual subprocess doesn't start until the first send() call - creation is cheap, startup is deferred.
A background task runs every 60 seconds and kills subprocesses that have been idle longer than CLAUDE_IDLE_TIMEOUT (default: 1800 seconds / 30 minutes). Set to 0 to disable eviction. Subprocesses with an active streaming response are never evicted mid-task.
This keeps memory usage proportional to active users, not total registered users. On a resource-constrained machine (like a Mac mini with 16GB), eviction prevents idle subprocesses from accumulating.
When a user's subprocess is evicted and later recreated, Kai restores their last workspace from the database. The saved path is validated against WORKSPACE_BASE and ALLOWED_WORKSPACES before being applied - stale or unauthorized paths are silently dropped.
For OS-level separation between users, set os_user in users.yaml. When configured, Kai spawns that user's Claude subprocess with sudo -u <os_user> claude ..., running it under a dedicated system account.
This creates a hard boundary: each user's Claude process runs with that OS account's UID, home directory, and file permissions. One user's subprocess literally cannot read another's files (assuming standard Unix permissions).
The CLAUDE_USER environment variable and per-user os_user in users.yaml serve the same purpose at different scopes:
-
os_userinusers.yamlapplies to that specific user's subprocess -
CLAUDE_USERapplies as a global fallback when there is nousers.yamlentry (or noos_userfield)
If both are set for a given user, the per-user os_user wins.
Each os_user account must:
- Exist on the system
- Have Claude Code CLI installed and accessible
- Have a sudoers rule allowing the service user to run
claudeas that account
The protected installer generates sudoers rules automatically during install apply. For manual setups, the rule looks like:
kai ALL=(alice) NOPASSWD: /path/to/claude
If you're already running Kai with ALLOWED_USER_IDS, the migration is straightforward:
- Create
users.yamlwith your existing user ID - Restart Kai
Existing data (sessions, jobs, workspace history) is automatically associated with the first authorized user. No database migration is needed - the schema already uses chat_id as a key.
This walkthrough covers adding a new user to an existing Kai instance. The steps differ slightly depending on whether you're running a development setup or a protected installation.
The new user should message @userinfobot on Telegram. It replies with their numeric user ID (e.g., 987654321).
If you're still using ALLOWED_USER_IDS in .env, now is the time to switch. Copy the example file:
# Development setup
cp users.yaml.example users.yaml
# Protected installation
sudo cp users.yaml.example /etc/kai/users.yamlAdd your own existing user first (so you don't lock yourself out), then add the new user.
Edit users.yaml (or /etc/kai/users.yaml for protected installations) and add an entry:
users:
- telegram_id: 123456789
name: alice
role: admin
github: alice-dev
home_workspace: /home/alice/workspace
# New user
- telegram_id: 987654321
name: bob
role: user
github: bobsmith
home_workspace: /home/bob/workspaceOnly telegram_id and name are required. Everything else is optional:
-
role- set toadminif they should receive GitHub/webhook notifications. Defaults touser. -
github- their GitHub username, for routing push/PR/issue notifications to them instead of to admins. -
home_workspace- their default workspace directory. If omitted, they use the global default. -
os_user- OS account for process isolation (see step 5). -
max_budget- ceiling for the/budgetcommand.
If you set home_workspace, make sure the directory exists and is writable by the service user (or the user's os_user if configured):
mkdir -p /home/bob/workspaceFor protected installations where the service runs as kai:
sudo mkdir -p /home/bob/workspace
sudo chown kai:kai /home/bob/workspaceIf you skip home_workspace, the new user shares the global default workspace. This is fine for simple setups but means they share a working directory with other users.
For OS-level separation, create a dedicated system account for the new user's Claude subprocess:
macOS:
sudo dscl . -create /Users/bob
sudo dscl . -create /Users/bob UserShell /usr/bin/false
sudo dscl . -create /Users/bob UniqueID 510 # pick an unused UID
sudo dscl . -create /Users/bob PrimaryGroupID 20
sudo dscl . -create /Users/bob NFSHomeDirectory /home/bob
sudo mkdir -p /home/bob
sudo chown bob:staff /home/bobLinux:
sudo useradd -r -m -d /home/bob -s /usr/sbin/nologin bobThen add os_user: bob to their entry in users.yaml.
For protected installations, re-running sudo python -m kai install apply regenerates the sudoers rules to include the new OS user. For development setups, manually add a sudoers rule:
kai ALL=(bob) NOPASSWD: /path/to/claude
The new OS user also needs Claude Code installed and accessible. Install it under their account or ensure the binary path in the sudoers rule is correct.
Development:
# Stop the running process (Ctrl+C or kill), then:
make runProtected installation (macOS):
# Find the PID and kill it - launchd auto-restarts
ps aux | grep kai
kill <pid>Protected installation (Linux):
sudo systemctl restart kaiKai logs the loaded user list at startup. Check the log to confirm:
tail -20 logs/kai.log # development
tail -20 /var/lib/kai/logs/kai.log # protectedHave the new user send a message to your Telegram bot. They should get a response. If they get no response, check the log for authorization errors - the most common cause is a mistyped telegram_id.
New users start with a clean state: no conversation history, no scheduled jobs, and the default workspace. Their subprocess is created lazily on first message.
Two environment variables relate to multi-user operation:
| Variable | Default | Description |
|---|---|---|
CLAUDE_USER |
Global fallback OS user for subprocess isolation (per-user os_user takes precedence) |
|
CLAUDE_IDLE_TIMEOUT |
1800 |
Seconds before idle subprocesses are evicted. Set to 0 to disable. |
- System Architecture - subprocess pool in the architecture diagram and message lifecycle
- Protected Installation - sudoers rules and directory layout for multi-user
- Workspaces - per-user workspace switching and configuration