-
Notifications
You must be signed in to change notification settings - Fork 19
Multi User Setup
Kai can serve multiple people from a single instance, with full isolation between them. Each person gets their own agent subprocess, conversation history, workspace state, scheduled jobs, and file storage.
People are canonical principals: records in Kai's database that exist independently of any messaging platform. How you add a person depends on the surface they use:
-
Workshop: provision the person and issue an enrollment token with the workshop CLI; no
users.yamlentry needed. The Workshop Operator Guide covers the full lifecycle. -
Telegram: add them to
users.yaml, as this page describes. The entry configures the Telegram transport for a person; enabling Telegram for someone already provisioned in the Workshop links the Telegram identity to the same canonical principal, keeping their channels and history.
users.yaml is therefore a Telegram-only concern, and optional when the Telegram adapter is disabled. On a Telegram-enabled install it is required; the former ALLOWED_USER_IDS fallback is retired and ignored.
Define Telegram 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 templates/users.yaml 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 | [] |
The admin-controlled authorization boundary: repos this user's PR review and issue triage may act on, and the baseline for webhook notifications. /github add grants notification subscriptions at runtime but never expands this operations allowlist. |
backend |
No | global default |
claude, codex, goose, opencode, or pi
|
provider |
No | global default | Provider for multi-provider backends |
models |
No | registry defaults | Per-role model map (agent, pr_review, issue_triage, memory_extraction, memory_episode, behavioral_judge, behavioral_gen) |
allowed_workspaces |
No | [] |
Per-user additions to the allowed workspace set |
allowed_services |
No | [] |
External proxy services this user's agent may call. Fail-closed: an empty list means no service calls, admins included |
allowed_triage_projects |
No | [] |
GitHub Project titles issue triage may assign to. Fail-closed: empty disables project assignment |
runtime_profile_id |
No | none | Installer-set link binding this Telegram identity to an existing canonical runtime profile (Workshop-provisioned humans) |
github_notify_chat_id |
No | none | Bootstrap-only: a negative (group) ID seeds a Workshop notification channel at startup. Live routing is per-person destination selection; see GitHub Notification Routing |
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. |
ALLOWED_USER_IDS is ignored entirely (with a logged warning if set). Without a valid users.yaml, a Telegram-enabled install 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 keyed to the person's canonical principal. There is no shared mutable state between people.
| Data | Isolation method |
|---|---|
| Conversation history | Canonical SQLite messages per channel, plus the JSONL archive at DATA_DIR/history/<channel_id>/ and derived per-channel transcript exports |
| Workspace selection | Canonical settings workspace authority, per runtime assignment |
| Scheduled jobs | Per-principal job authority (principal, channel, agent, runtime profile) |
| File uploads | Saved to DATA_DIR/files/<principal_id>/
|
| Semantic memory | Principal-keyed namespace with four-key provenance |
| Preferences |
DATA_DIR/preferences/<principal_id>/PREFERENCES.md with revision history |
| Voice preferences | Per client binding (each enrolled surface separately) |
| Runtime storage | Per-principal storage namespaces behind the runtime profile |
| Crash recovery | Durable update queue in kai.db; interrupted messages requeue per principal |
| Session tracking | One row per runtime lane in the sessions table |
Each user gets their own Claude Code subprocess, managed by a pool (pool.py).
Subprocesses are not spawned at startup. When a person sends their first message, the pool creates a backend instance with their configuration (backend, os_user, home_workspace). The actual subprocess doesn't start until the first send - 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 five backends (Claude, Codex, Goose, OpenCode, Pi), for chat sessions and for the one-shot processes (memory fact extraction, episodes, review, triage).
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 upgrading an old install that used ALLOWED_USER_IDS (the fallback is now removed, so this migration is mandatory):
- Create
users.yamlwith your existing user ID - Restart Kai
Existing data (sessions, jobs, workspace history) is automatically associated with the first authorized user, and the startup migrations re-key legacy chat-id data to canonical principals with receipts; nothing manual is needed.
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 |
Retired; set per-user pr_review in users.yaml or toggle in Workshop Settings |
ISSUE_TRIAGE_ENABLED |
Retired; set per-user issue_triage in users.yaml or toggle in Workshop Settings |
GITHUB_NOTIFY_CHAT_ID |
Retired; notification destinations are chosen per person (Workshop Settings or /notifications) |
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 the file doesn't exist yet, copy the template. Note the destinations: single-user installs read ~/.config/kai/users.yaml, protected installs read /etc/kai/users.yaml; the project root is not consulted.
# Single-user setup
mkdir -p ~/.config/kai && cp templates/users.yaml ~/.config/kai/users.yaml
# Protected installation
sudo cp templates/users.yaml /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- the admin-controlled authorization boundary for review and triage, and the notification baseline. Runtime/github addsubscriptions grant notifications only, never operations. -
github_notify_chat_id- bootstrap-only seeding of a group-backed notification channel; live routing is chosen per person. 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