Skip to content

Multi User Setup

Daniel Ellison edited this page Mar 26, 2026 · 3 revisions

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.

Configuration

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.0

Copy users.yaml.example from the repo to get started.

Field reference

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).

Roles

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.

GitHub actor routing

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)

Per-user data isolation

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

Subprocess pool

Each user gets their own Claude Code subprocess, managed by a pool (pool.py).

Lazy creation

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.

Idle eviction

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.

Workspace restoration

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.

Process isolation

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).

Precedence with CLAUDE_USER

The CLAUDE_USER environment variable and per-user os_user in users.yaml serve the same purpose at different scopes:

  • os_user in users.yaml applies to that specific user's subprocess
  • CLAUDE_USER applies as a global fallback when there is no users.yaml entry (or no os_user field)

If both are set for a given user, the per-user os_user wins.

Requirements

Each os_user account must:

  1. Exist on the system
  2. Have Claude Code CLI installed and accessible
  3. Have a sudoers rule allowing the service user to run claude as 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

Migration from single-user

If you're already running Kai with ALLOWED_USER_IDS, the migration is straightforward:

  1. Create users.yaml with your existing user ID
  2. 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.

Adding a user

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.

1. Get their Telegram user ID

The new user should message @userinfobot on Telegram. It replies with their numeric user ID (e.g., 987654321).

2. Create users.yaml (if it doesn't exist)

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.yaml

Add your own existing user first (so you don't lock yourself out), then add the new user.

3. Add the user entry

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/workspace

Only telegram_id and name are required. Everything else is optional:

  • role - set to admin if they should receive GitHub/webhook notifications. Defaults to user.
  • 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 /budget command.

4. Create their home workspace

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/workspace

For protected installations where the service runs as kai:

sudo mkdir -p /home/bob/workspace
sudo chown kai:kai /home/bob/workspace

If 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.

5. (Optional) Set up process isolation

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/bob

Linux:

sudo useradd -r -m -d /home/bob -s /usr/sbin/nologin bob

Then 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.

6. Restart Kai

Development:

# Stop the running process (Ctrl+C or kill), then:
make run

Protected installation (macOS):

# Find the PID and kill it - launchd auto-restarts
ps aux | grep kai
kill <pid>

Protected installation (Linux):

sudo systemctl restart kai

Kai 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  # protected

7. Verify

Have 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.

Environment variables

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.

Related pages

Clone this wiki locally