Skip to content

Multi User Setup

Daniel Ellison edited this page Jun 22, 2026 · 18 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

  - telegram_id: 987654321
    name: bob
    role: user
    github: bobsmith
    home_workspace: /home/bob/workspace

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

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

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

Backend coverage

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_user needs 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-user backend: goose entries in users.yaml); add the entries first, then run make config so 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_BIN and OPENCODE_BIN serve the same purpose for their backends. The wizard collects these paths whenever the backend is in use, whether globally or through per-user backend entries in users.yaml.

OpenCode-specific notes:

  • OpenCode reads its auth from ~/.local/share/opencode/auth.json, which is per-OS-user: run opencode auth login while logged in as each os_user so the credentials land under that account's home. The sudo -H wrap 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.

Requirements

Each os_user account must:

  1. Exist on the system
  2. Have the user's agent binary installed and accessible (claude, codex, goose, or opencode)
  3. 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

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.

What carries over automatically

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

What you must set manually

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-repo

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

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.
  • workspace_base - their base directory for /workspace new and name resolution. Overrides WORKSPACE_BASE for this user.
  • os_user - OS account for process isolation (see step 5).
  • model, timeout - per-user defaults for agent subprocess settings.
  • github_repos - list of owner/repo strings to subscribe to for webhook events. Sets the admin baseline; the user can add/remove repos at runtime via /github add and /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.

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

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

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.

Related pages

Clone this wiki locally