Skip to content

Adding a User on macOS

Daniel Ellison edited this page Sep 3, 2026 · 1 revision

Adding a User on macOS

A step-by-step walkthrough for adding a new user to an existing Kai protected installation running on macOS. This covers every detail: finding the Telegram ID, editing users.yaml, creating the home workspace, optional OS-level process isolation, restarting the launchd service, and verifying the new user can reach Kai.

If you want the shorter cross-platform version, see the "Adding a user" section in Multi-User Setup. This page exists because macOS has several platform-specific details (dscl for user accounts, launchd for service control, Homebrew Python's PID quirk, file-mode traversal on /Users) that deserve their own walkthrough.

This guide assumes:

  • Kai is already running as a protected installation on a Mac mini (or any macOS host)
  • Source lives at /opt/kai/, data at /var/lib/kai/, secrets at /etc/kai/
  • The service is com.syrinx.kai loaded as a LaunchDaemon
  • You have sudo on the host
  • You are the admin adding the user, not the user being added

If your deployment is development-mode (make run from the project directory, no /etc/kai/), the same steps apply but users.yaml lives at the project root and restart is just Ctrl+C plus make run again.

Overview

This page covers the Telegram path: a person who will talk to Kai through the Telegram bot, defined by a users.yaml entry. On a current install there is a second door with no yaml at all: provisioning a Workshop human with the CLI (see The Workshop path below), which is the right choice when the person will use the browser client. Enabling Telegram later for a Workshop-provisioned person links the identity to the same canonical human.

Adding a Telegram user on a protected install is six required steps:

  1. Get the user's Telegram ID
  2. Create their dedicated macOS account (process isolation is required)
  3. Edit /etc/kai/users.yaml, including os_user
  4. Create their home workspace directory (or let the installer create the default)
  5. Re-run sudo make install (regenerates sudoers, restarts the service)
  6. Verify by having them send a message

Optional:

  • GitHub routing - route webhook notifications (pushes, PRs, issues) for specific repos to them instead of admins
  • Per-user model / timeout / backend - override the defaults just for this user

Total time: about fifteen minutes.

The Workshop path

When the new person will use the browser client, skip users.yaml entirely and provision them canonically (all commands as the OS account owning the data directory):

python -m kai workshop client-access provision-human \
  --provisioning-key <key> --display-name "Bob" --role member
python -m kai workshop client-access list-runtime-profiles
python -m kai workshop client-access assign-runtime \
  --principal-id <P> --channel-id <C> --runtime-profile-id <R>
python -m kai workshop client-access issue-enrollment --principal-id <P>

That creates the canonical human and their direct channel, grants a runtime, and mints the enrollment token they redeem in the browser. They still need a dedicated macOS account, granted through the runtime profile (make runtime-access) rather than users.yaml. To add Telegram later, give their users.yaml entry the installer-set runtime_profile_id link so the Telegram identity attaches to the same human instead of creating a duplicate. Full lifecycle in the Workshop Operator Guide.

Prerequisites

Before starting, confirm the current install is healthy:

python -m kai install status

Expect a long report: paths and ownership up top, then a status line per Workshop authority (bootstrap, runtime profiles, delivery, memory authority, and many more). What you're looking for is the absence of failures and traversal warnings, not any particular line count. If install status reports missing pieces or traversal issues, fix those first; adding a user to a broken install only adds confusion to the debugging.

Also confirm you can see the existing users.yaml:

sudo cat /etc/kai/users.yaml

If the file exists, good. You'll be appending a new entry. If it does not exist yet, either your install is workshop-only (Telegram disabled, no users.yaml needed) or the Telegram adapter was never configured; run make config to generate one. The former ALLOWED_USER_IDS fallback is retired and ignored.

Step 1: Get the user's Telegram ID

Telegram identifies every account with a numeric ID. Kai authorizes users by that number, not by username (usernames can be changed; IDs cannot). The new user needs to look up their own ID and send it to you.

Ask them to:

  1. Open Telegram and search for @userinfobot
  2. Start a chat with it and press "Start" (or send any message)
  3. Copy the Id: value from the reply

Example reply:

Id: 987654321
First: Bob
Last: Smith
Lang: en

Hand you just the ID: 987654321. Write it down; you'll paste it into users.yaml in Step 3.

A few notes on this step that trip people up:

  • @userinfobot sometimes lags. If it doesn't reply within a minute, try @myidbot instead. Both return the same ID.
  • The ID is always a positive integer. If you see a negative number, that's a group chat ID, not a user ID.
  • Telegram usernames (the @handle) are not involved here. Kai does not use them for authorization at all.

Step 2: Decide on their role and options

Before editing the file, decide three things so you can write the entry in one pass.

Role: admin or user?

Two roles exist:

  • admin receives notifications for unattributed external events: GitHub pushes from unknown contributors, generic webhook payloads, anything that can't be mapped to a specific user. At least one admin must exist; if none do, those events are dropped with a warning.
  • user interacts through Telegram only. They don't receive webhook notifications unless a specific GitHub event is attributed to them via the github field.

Default is user. Make them admin only if they should share the responsibility of watching over the instance. For a shared family account, "user" is usually the right call.

Process isolation is required

On a protected install, every interactive user must have a dedicated macOS account named in os_user, distinct from the service account and unique per person. The apply step validates this before touching anything: an entry without a valid, existing os_user makes sudo make install abort with no changes made.

Kai spawns the person's agent subprocess with sudo -u <account> ..., running it under their own UID. That subprocess cannot read another user's files, write into /etc/kai/, or touch the Kai database.

Account setup is in Step 6; do it before the restart in Step 5.

What should their home workspace be?

Every user has a "home workspace" - the default directory their agent subprocess starts in.

  • The default (no home_workspace field): each person lands in their own directory under the data directory (/var/lib/kai/home/<principal_id>/). The installer pre-creates it, seeds it with AGENTS.md, and chowns it to their os_user. Nothing for you to do.
  • An override outside the data directory (e.g. /Users/bob/workspace): the installer deliberately does not create or chown paths outside the data directory; you create it and set ownership yourself in Step 4, and it must exist before restart.

Step 3: Edit /etc/kai/users.yaml

The file is owned by root with mode 0600, so you need sudo. Open it in your editor:

sudo vim /etc/kai/users.yaml

(Or sudo nano /etc/kai/users.yaml if you prefer.)

The minimum entry

Append the new user to the users: list. Only telegram_id and name are required:

users:
  - telegram_id: 123456789
    name: alice
    role: admin
    github: alice-dev
    os_user: alice
    home_workspace: /Users/alice/workspace

  # New user
  - telegram_id: 987654321
    name: bob

That's it. Save the file and jump to Step 4.

The full entry (for reference)

Every field the schema accepts, with its effect:

  - telegram_id: 987654321          # required, their numeric Telegram ID
    name: bob                        # required, display name for logs
    role: user                       # 'admin' or 'user', default 'user'
    github: bobsmith                 # GitHub username for actor routing
    os_user: bob                     # macOS account for process isolation
    home_workspace: /Users/bob/kai   # their default workspace directory
    workspace_base: /Users/bob       # base for /workspace new and name resolution
    model: sonnet                    # any curated alias (fable/opus/sonnet/haiku,
                                     # runtime selectors) or a full model ID
    timeout: 120                     # seconds without output before timing out
    github_repos:                    # authorization baseline for review/triage,
      - bobsmith/their-repo          # and the webhook events they receive
    github_notify_chat_id: -100123   # bootstrap-only: seeds a notification channel
    pr_review: true                  # PR review agent baseline for them
    issue_triage: true               # issue triage agent baseline for them

Further fields exist for backend routing and containment (backend, provider, the per-role models map, allowed_workspaces, allowed_services, allowed_triage_projects, runtime_profile_id); Multi-User Setup documents them all.

If you don't specify model, they get the registry default for their backend and provider (the old DEFAULT_MODEL env var is retired). Two fields are fail-closed with no fallback: empty github_repos means no webhook events and no agent authority, and empty allowed_services means no external service calls.

YAML gotchas

A few things the parser won't forgive:

  • Tabs vs spaces: YAML is whitespace-sensitive and tabs are illegal at the start of a key. Use two-space indentation like the example.
  • Quote things with colons: if a value contains a : (a URL, a port), wrap it in quotes.
  • Numeric IDs are integers, not strings: telegram_id: 987654321, not telegram_id: "987654321". Kai accepts either but integers are the convention.
  • Duplicate telegram_id: the loader logs a warning and keeps the first entry. If you see one user getting another's config, check for duplicates.

Validate before saving

If you have yq or Python available, a quick sanity check catches typos:

sudo cat /etc/kai/users.yaml | python3 -c "import sys, yaml; yaml.safe_load(sys.stdin); print('OK')"

OK means the YAML parses. It doesn't check semantic correctness (valid Telegram IDs, existing paths, etc.) but it catches the 90 percent of mistakes that are just syntax errors.

Step 4: Create the home workspace

If you set home_workspace in the entry, the directory must exist before Kai restarts. If the service user can't cd into it, the subprocess crashes on first message.

If workspace is under /opt/kai/, /var/lib/kai/, or another root-owned path

Create it as root, then chown to the service user:

sudo mkdir -p /Users/bob/workspace
sudo chown kai:staff /Users/bob/workspace

Replace kai:staff with your actual service user. If you set os_user: bob, chown to bob:staff instead - it needs to be writable by whoever the Claude subprocess runs as.

If workspace is under /Users/<someone>/...

/Users/<someone>/ on macOS is typically mode 700 or 750, which blocks other accounts from traversing in. Check:

ls -ld /Users/bob

If you see drwx------ or drwxr-x---, the service user can't enter it. Either change the permissions to allow traversal:

sudo chmod o+x /Users/bob

(This lets anyone cd /Users/bob but not ls or read files; it's the minimum needed for Claude to cd to a subdirectory under it.)

Or skip the override and use the default home under the data directory, which the installer creates and chowns for you.

python -m kai install status has a workspace traversal check that catches this - it prints the exact chmod command to fix any problem it finds. Re-run it after creating the directory.

Populating the workspace

A default home workspace comes pre-seeded by the installer with AGENTS.md (the canonical identity file, from the repo's templates). For an override workspace outside the data directory, copy the template in yourself if you want the standard identity present:

sudo cp /opt/kai/src/templates/AGENTS.md /Users/bob/workspace/
sudo chown bob:staff /Users/bob/workspace/AGENTS.md

Kai also works fine starting from an empty directory and letting the user grow it themselves.

Step 5: Restart the Kai service (macOS launchd)

Changes to users.yaml take effect at startup. There is no live reload. On macOS the service is a LaunchDaemon, not a user agent, so you need sudo to stop it. KeepAlive is set in the plist, so once you kill the process it auto-restarts within a second or two.

The simplest restart:

ps aux | grep kai

Look for two lines that aren't the grep itself: a bash /opt/kai/run.sh parent and a Python child. Note the PID of the bash wrapper (the leftmost numeric column after the user name). Kill it:

sudo kill <pid>

launchd notices the exit and respawns both processes within a second. Confirm:

ps aux | grep kai

You should see two new PIDs.

Why kill the parent, not the child

The run.sh wrapper exists specifically because Homebrew Python re-execs through Python.app, creating a grandchild with a new PID that launchd can't track. If you kill only the Python process, run.sh dies too because it's tailing the log and waiting on the Python PID; launchd then sees run.sh exit and restarts the pair. Killing the parent bash achieves the same effect more directly.

Either way works. Killing the parent is cleaner.

Why you can't use launchctl

launchctl list | grep kai does NOT work for LaunchDaemon services - it only shows agents running in your user session. To interact with the daemon you'd need sudo launchctl bootout system/com.syrinx.kai followed by sudo launchctl bootstrap system /Library/LaunchDaemons/com.syrinx.kai.plist, which is longer than kill and prone to transient failures when launchd hasn't fully torn down the previous service domain.

The kill + KeepAlive pattern is simpler and is the one the install script itself uses internally.

Check the logs

Tail the log to see the startup banner:

tail -30 /var/lib/kai/logs/kai.log

You want to see a line like:

[INFO] config: loaded 2 users from /etc/kai/users.yaml: alice(admin), bob(user)

If the count is wrong or the name is missing, there's an issue with your edit. Jump to Troubleshooting.

If the log warns about ALLOWED_USER_IDS, that variable is retired and ignored; remove it from the env file to quiet the warning. Authorization comes from users.yaml alone.

Step 6: macOS process isolation

Required on protected installs, and best done before the Step 5 restart: sudo make install refuses to apply while any interactive user lacks a valid os_user.

Creating a dedicated macOS account for the new user's Claude subprocess adds a real security boundary: their subprocess runs under a different UID and cannot read other users' files through normal filesystem permissions.

Skip this step if:

  • You trust everyone on the instance equally (family sharing, just yourself across devices, etc.)
  • You don't want to manage extra accounts

Do this step if:

  • You're sharing the instance with people outside your trust circle
  • You want defense-in-depth against a compromised Claude subprocess reaching another user's files

Create the macOS account

macOS account creation uses dscl (Directory Service command line). The sequence below creates a non-login service account named bob with a home directory at /Users/bob.

First, pick an unused UID. Run this to see what's in use:

dscl . -list /Users UniqueID | awk '{print $2}' | sort -n | tail

Pick a number higher than the highest existing UID that's not already reserved. 510, 511, 512 are typical safe choices if your existing non-system users are in the 500s.

Create the account:

UID_TO_USE=510
sudo dscl . -create /Users/bob
sudo dscl . -create /Users/bob UserShell /usr/bin/false
sudo dscl . -create /Users/bob RealName "Bob Kai User"
sudo dscl . -create /Users/bob UniqueID $UID_TO_USE
sudo dscl . -create /Users/bob PrimaryGroupID 20
sudo dscl . -create /Users/bob NFSHomeDirectory /Users/bob
sudo mkdir -p /Users/bob
sudo chown bob:staff /Users/bob

Notes on each line:

  • UserShell /usr/bin/false prevents interactive login. Kai only runs claude as this user via sudo -u, never a shell.
  • RealName is cosmetic, used by id and who. Set it to something recognizable.
  • PrimaryGroupID 20 is staff on macOS (the default for regular users).
  • Without the final mkdir + chown, the account exists but has no home directory. Claude Code CLI expects ~/.claude/ to be writable.

Verify the account works:

id bob

You should see uid=510(bob) gid=20(staff) groups=20(staff) or similar.

Install Claude Code CLI for the new account

Every macOS account needs its own ~/.claude/ directory and binary access. If Claude is installed globally (e.g., under ~/.local/bin/claude of the service user), the simplest option is to symlink:

sudo mkdir -p /Users/bob/.local/bin
sudo ln -s /Users/kai/.local/bin/claude /Users/bob/.local/bin/claude
sudo chown -R bob:staff /Users/bob/.local

Confirm the account can execute it:

sudo -u bob /Users/bob/.local/bin/claude --version

If it prints a version string, you're good.

Authenticate Claude Code for the new account

The binary is reachable, but the account has no Anthropic credentials. Until the new account authenticates, every sudo -u bob claude ... from Kai will fail with an auth error and the user's first message will hang or error.

Run the auth flow as the new account, with a login shell so $HOME resolves to /Users/bob/:

sudo -u bob -i /Users/bob/.local/bin/claude auth login

This opens an interactive OAuth flow in the operator's terminal. Complete it - the credentials land in /Users/bob/.claude/ and persist across restarts.

If you prefer a long-lived token (no browser flow, requires a Claude subscription), use claude setup-token in place of auth login.

Confirm:

sudo -u bob -i /Users/bob/.local/bin/claude auth status

You should see the authenticated account. If it reports no credentials, the OAuth flow didn't complete - re-run auth login.

Add os_user to users.yaml

Edit /etc/kai/users.yaml again and add os_user: bob to their entry:

  - telegram_id: 987654321
    name: bob
    os_user: bob
    home_workspace: /Users/bob/workspace

Regenerate sudoers rules

The install apply step automatically writes sudoers rules for every os_user declared in users.yaml. Re-run it to pick up the new account:

sudo python -m kai install apply

This does a full apply cycle (stop service, re-copy source, re-write secrets, re-generate sudoers, restart service). It takes about 30 seconds and is safe on an existing install; the venv is only rebuilt if pyproject.toml changed.

If you want to preview what install apply will do first:

sudo python -m kai install apply --dry-run

Every action prints with [DRY RUN] prefix. Use this to confirm the sudoers rules look right before committing.

After apply, check the generated sudoers file:

sudo cat /etc/sudoers.d/kai

You should see one block per target user, five agent rules plus the kill rule:

kai ALL=(bob) CWD=* SETENV: NOPASSWD: /Users/kai/.local/bin/claude
kai ALL=(bob) CWD=* SETENV: NOPASSWD: /usr/local/bin/codex
kai ALL=(bob) CWD=* SETENV: NOPASSWD: /usr/local/bin/opencode
kai ALL=(bob) CWD=* SETENV: NOPASSWD: /opt/homebrew/bin/goose
kai ALL=(bob) CWD=* SETENV: NOPASSWD: /opt/homebrew/bin/pi
kai ALL=(bob) NOPASSWD: /bin/kill

The paths come from /etc/kai/backends.yaml; every installed backend gets a rule regardless of which is active. These are what let the service user spawn agents as bob without a password.

Verify process isolation

Have the new user send a message to Kai, then check what runs:

ps aux | grep claude

You should see a claude process owned by bob, not kai. If it's owned by kai, the os_user field isn't being honored - check the startup log for warnings about users.yaml.

Step 7 (optional): GitHub routing

If the new user contributes to GitHub repos and you want them to receive webhook notifications (pushes to their branches, PR reviews, issue comments) instead of those events going to admins, set github and github_repos in their entry.

  - telegram_id: 987654321
    name: bob
    github: bobsmith
    github_repos:
      - bobsmith/their-app
      - some-org/shared-repo

The github field is just their GitHub username. Kai uses it to match webhook actors: when a push arrives with pusher.name == "bobsmith", Kai routes it to Bob instead of to admins.

The github_repos field is the list of repositories Bob subscribes to. Webhook events from those repos go to Bob; events from other repos go to admins (or whoever else has that repo in their github_repos).

Alternatively, Bob can self-manage his subscriptions from Telegram:

/github add bobsmith/their-app
/github remove bobsmith/their-app

These persist to the database (not users.yaml) so they survive restarts. users.yaml sets the baseline; /github add and /github remove let users extend or trim it.

See GitHub Notification Routing for the full picture including PR review agent, issue triage agent, and chat-based routing.

Step 8: Verify

Have the new user send a message to your bot's Telegram handle. They should see a response within a few seconds.

Common first-message issues

No response at all: check kai.log for authorization errors. The most common cause is a mistyped telegram_id. Fix the number and restart.

tail -50 /var/lib/kai/logs/kai.log | grep -i unauth

"Permission denied" errors in the log: if they set os_user and the Claude process can't start as that user, either the macOS account doesn't exist, the sudoers rule is missing, or the Claude binary isn't reachable from that account. Work through Step 6 again.

"Could not cd to workspace": the home_workspace path doesn't exist or isn't traversable by the subprocess's UID. Run python -m kai install status to get the specific fix.

Response arrives but cuts off: check the user's timeout field. If unset, it uses the global default. If set low, they may hit the limit on the first complex prompt.

Expected first-message log lines

On a healthy first exchange, the log shows:

[INFO] bot: authorized user 987654321 (bob)
[INFO] pool: created PersistentClaude for chat 987654321 (home: /Users/bob/workspace)
[INFO] claude: subprocess started as bob
[INFO] claude: first response streamed (123 tokens)

If any line is missing or reports a different user, that's the step that broke.

Troubleshooting

users.yaml: invalid telegram_id Must be a positive integer. Check for quotes turning it into a string, for minus signs (group IDs), or for copy-paste that grabbed extra whitespace.

users.yaml: skipping entry without name Every entry needs name. Add one.

users.yaml: duplicate telegram_id X; using first entry You have two entries with the same ID. The first one wins. Remove the duplicate.

sudo: kai is not in the sudoers file The sudoers rules weren't generated, or they were generated for the wrong service user. Re-run sudo python -m kai install apply; it reads the actual service user from install.conf and writes rules matching it.

claude: command not found under os_user The new OS account can't reach the Claude binary. Either install Claude for that account or symlink it from an accessible location. The sudoers rule must reference the same path the service process tries to execute.

launchctl errors about "service already loaded" You tried to bootstrap a service that's already running. This is harmless; the kill + KeepAlive approach doesn't run into this because launchd manages the service domain, not you.

The new user's first message hangs Give it 30 seconds on the first exchange. The subprocess pool creates their Claude instance lazily on first message, which involves spawning a new process, loading initial context, and establishing the stream-json connection. Subsequent messages are fast.

Homebrew Python re-exec confusion If ps aux | grep kai shows three processes instead of two (bash + Python + Python), that's the Homebrew Python framework re-exec. It's normal. run.sh is the wrapper that keeps launchd tracking the right PID despite this.

Removing a user

Reverse of adding. Edit /etc/kai/users.yaml, remove their entry, and restart. For Workshop access, also revoke their client credentials:

python -m kai workshop client-access revoke-device --principal-id <P> --device-id <D>
python -m kai workshop client-access revoke-enrollment --principal-id <P> --grant-id <G>

Their on-disk data stays:

  • /var/lib/kai/history/<channel_id>/ - conversation history archive (JSONL)
  • /var/lib/kai/files/<principal_id>/ - uploads
  • /var/lib/kai/memory/<principal_id>/ and /var/lib/kai/preferences/<principal_id>/ - their memory and preference files
  • Their canonical records in kai.db (the event store is append-only; messages, runs, and jobs stay)

Nothing deletes these automatically, and the canonical event store is not something to scrub by hand; removing the yaml entry and revoking credentials ends their access, which is the part that matters. Directory cleanup for the per-person file trees is safe if you want the space back.

If you set up a dedicated os_user, you can also delete the macOS account:

sudo dscl . -delete /Users/bob
sudo rm -rf /Users/bob

And re-run sudo python -m kai install apply to regenerate sudoers without the stale rule.

Related pages

Clone this wiki locally