-
Notifications
You must be signed in to change notification settings - Fork 17
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
- telegram_id: 987654321
name: bob
role: user
github: bobsmith
home_workspace: /home/bob/workspaceCopy 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 |
workspace_base |
No | global WORKSPACE_BASE
|
Base directory for /workspace new and name resolution |
model |
No | global DEFAULT_MODEL
|
Default model: haiku, sonnet, or opus
|
timeout |
No | global DEFAULT_TIMEOUT
|
Response timeout in seconds (max 600) |
github_repos |
No | [] |
List of owner/repo strings this user receives webhook events for. Sets the admin baseline; users can extend or trim the list at runtime via /github add and /github remove. |
github_notify_chat_id |
No | global GITHUB_NOTIFY_CHAT_ID or DM |
Telegram chat ID (negative for groups) where this user's GitHub notifications are sent instead of their DM |
pr_review |
No | global PR_REVIEW_ENABLED
|
Enable or disable the PR review agent for this user (true/false). Overrides the global setting. |
issue_triage |
No | global ISSUE_TRIAGE_ENABLED
|
Enable or disable the issue triage agent for this user (true/false). Overrides the global setting. |
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 AGENT_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 agent subprocess with sudo -H -u <os_user> ..., running it under a dedicated system account.
This creates a hard boundary: each user's agent 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).
os_user isolation applies to all four backends (Claude, Codex, Goose, OpenCode), for chat sessions and for the one-shot processes (memory fact extraction, episodes).
Goose-specific notes:
- Each goose
os_userneeds provider auth reachable from its environment. Provider API keys set in the service environment are preserved through the sudo boundary automatically; keychain-based auth (goose configure) is stored per OS account and must be set up while logged in as that user. The wizard collects the provider API keys goose entries need (including per-userbackend: gooseentries in users.yaml); add the entries first, then runmake configso the scan sees them. Goose's endpoint-override variables (ANTHROPIC_HOST,OPENAI_HOST,OPENAI_BASE_PATH,OLLAMA_HOST) survive the boundary the same way, so custom-endpoint and gateway installs keep their routing under per-user isolation. - The installer deploys the Goose extension config to each goose-backed
os_user's home in addition to the service user's, owned by that account. - Set
GOOSE_BIN(the wizard prompts for it) so the sudoers rule and the spawned binary agree on one absolute path.CODEX_BINandOPENCODE_BINserve the same purpose for their backends. The wizard collects these paths whenever the backend is in use, whether globally or through per-userbackendentries in users.yaml.
OpenCode-specific notes:
- OpenCode reads its auth from
~/.local/share/opencode/auth.json, which is per-OS-user: runopencode auth loginwhile logged in as eachos_userso the credentials land under that account's home. Thesudo -Hwrap points the subprocess at them; the wizard does not manage OpenCode auth state. - Model selection (delivered via
OPENCODE_CONFIG_CONTENT) and provider API keys set in the service environment are preserved through the sudo boundary automatically.
Each os_user account must:
- Exist on the system
- Have the user's agent binary installed and accessible (claude, codex, goose, or opencode)
- Have sudoers rules allowing the service user to run the agent as that account
The protected installer generates sudoers rules automatically during install apply: one SETENV: rule per agent binary, pinned to an absolute path, plus a /bin/kill rule the bot uses to clean up the target user's process tree on timeouts and shutdown. For manual setups, the rules look like:
kai ALL=(alice) SETENV: NOPASSWD: /path/to/claude
kai ALL=(alice) NOPASSWD: /bin/kill
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.
Most previously global settings continue to work as env var fallbacks. You don't have to set them per-user unless you want to override them:
| What you had | How it works after migration |
|---|---|
PR_REVIEW_ENABLED |
Still respected globally; per-user pr_review in users.yaml overrides it |
ISSUE_TRIAGE_ENABLED |
Still respected globally; per-user issue_triage overrides it |
GITHUB_NOTIFY_CHAT_ID |
Still respected globally; per-user github_notify_chat_id overrides it |
CLAUDE_USER |
Retired. Set os_user per user in users.yaml instead |
CLAUDE_MODEL, CLAUDE_TIMEOUT_SECONDS, etc. |
Retired in favor of DEFAULT_MODEL, DEFAULT_TIMEOUT; per-user fields in users.yaml override the globals |
One field has no env var equivalent and will not be inferred from your existing config: github_repos.
Before the multi-user buildout, GitHub webhook events were broadcast globally - any enabled user received them. Now, events are routed to the specific user whose github_repos list matches the incoming repo. If github_repos is empty (the default), no events are delivered to that user, including PR reviews and issue triage.
After migrating to users.yaml, add the repos you want to receive events for. You can do this in users.yaml (admin, persistent across restarts) or via Telegram (self-service, stored in the database).
Option A: users.yaml
users:
- telegram_id: 123456789
name: alice
role: admin
github: alice-dev
github_repos:
- alice-dev/my-repo
- alice-dev/another-repoOption B: Telegram self-service
/github add alice-dev/my-repo
/github add alice-dev/another-repo
If you have a GitHub token stored (/github token <value>), Kai registers the webhooks automatically. Without a token, it prints the manual registration instructions.
Both options can be combined. The yaml baseline is set by the admin; users can add or remove repos on top of it at any time without touching the config file.
Also populate the github field (your GitHub username). It is required for GitHub actor routing to work - without it, events you triggered (your own pushes, your own PR reviews) are treated as coming from an unknown actor and routed to admins instead of to you.
If you only have one user, a quick way to receive all events for all repos is to add yourself as an admin and list your repos. Admins also receive events from repos with no matching subscriber.
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. -
workspace_base- their base directory for/workspace newand name resolution. OverridesWORKSPACE_BASEfor this user. -
os_user- OS account for process isolation (see step 5). -
model,timeout- per-user defaults for agent subprocess settings. -
github_repos- list ofowner/repostrings to subscribe to for webhook events. Sets the admin baseline; the user can add/remove repos at runtime via/github addand/github remove. -
github_notify_chat_id- route this user's GitHub notifications to a specific chat instead of their DM. See GitHub Notification Routing. -
pr_review,issue_triage- enable or disable the review/triage agents specifically for this user, overriding the global setting.
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 agent 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 rules for the user's agent binary and the cleanup kill path:
kai ALL=(bob) SETENV: NOPASSWD: /path/to/claude
kai ALL=(bob) NOPASSWD: /bin/kill
The new OS user also needs their agent binary (claude, codex, or goose) installed and accessible; make sure the binary path in the sudoers rule matches the one the bot spawns (CODEX_BIN / GOOSE_BIN pin it for those backends). Goose users additionally need provider auth reachable as that account; see Process isolation.
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.
One environment variable relates to multi-user operation:
| Variable | Default | Description |
|---|---|---|
AGENT_IDLE_TIMEOUT |
1800 |
Seconds before idle subprocesses are evicted. Set to 0 to disable. |
OS-level subprocess isolation is configured per user via os_user in users.yaml (see Process isolation); the former CLAUDE_USER global fallback is retired.
- Adding a User on macOS - comprehensive macOS-specific walkthrough with dscl, launchd, and file-mode traversal details
- 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