Angelia

Docs · docs/runbook.md on GitHub

Runbook

How to run one Angelia instance day to day, and what to do when it misbehaves. The README says what Angelia is; this says what to type. Paths are the defaults (~/.angelia, ANGELIA_STATE_DIR moves it).

Install and first run

curl -fsSL <the install.sh in the repository> | sh   # Node 22.23.4+, 24.22.1+ or 26.12.1+, git; the README has the exact line
angelia init                    # bot token, which CLI, first directory, pair a chat
angelia service install         # macOS: a LaunchAgent that starts at login and returns after a crash
angelia status                  # instance paths, daemon pid, each platform's state, warm sessions

Without the service: angelia daemon in a terminal you keep open (it reads ~/.angelia/env itself), or angelia restart, which starts it detached.

Every day

I want toCommand
see what is runningangelia status; in a chat, /status
validate the table after an editangelia check-config
apply an edited table/restart in a chat you own, or angelia restart from a terminal
update the codeangelia update (--check only lists), then /restart
see or add profilesangelia profiles, angelia profile add <platform:chat> [name]
post into a chat from a scriptangelia send <platform:chat> <text>, angelia send-media <platform:chat> </abs/path> [caption]
run a prompt in a chat's sessionangelia turn <platform:chat> <text>
scheduled workangelia-jobs.yaml in the profile folder, then angelia jobs install
read a chat's historyangelia export <platform:chat> [--all] [--tools]
link or unlink WhatsAppangelia pair, angelia unpair
version-control the profilesangelia workspace sync (put it in a job to run it nightly)
the manual an agent readsangelia guide [topic]

The daemon reads the table only at start. Every edit needs a restart; /restart checks the table first and refuses a broken one.

Where to look

WhatWhere
ids, counts, turn failures, restarts~/.angelia/daemon.log, then daemon.log.1 (no message text except a dying CLI's last error line)
the screen of a tmux pane that never reached a prompt~/.angelia/tui/<pane>/screen.txt
the daemon's own stdout and stderr~/.angelia/daemon.out
what the service doesangelia service status; launchctl print gui/$(id -u)/angelia.daemon
a job timer's output~/.angelia/jobs.log, ~/.angelia/jobs.out
a tui pane that will not answertmux -L angelia ls, then tmux -L angelia attach -t <name> (detach with C-b d)
the agent's own transcript~/.claude/projects/<cwd with non-alphanumerics as ->/<session>.jsonl

The first thing to read when a chat says "Something broke on my side" is the turn failed line in daemon.log. It carries the reason: the CLI not found, a session that did not resume, a pane that never reached a prompt. The chat line carries the same reason.

Known failures and their fixes

"This chat's agent (claude) is not installed where Angelia can find it." The CLI is not on the daemon's PATH. angelia service install rewrites the plist with a PATH that includes the folder of every CLI the table names; or set bin: on the profile to the full path.

Every chat fails after a profile folder moved. Claude Code keys transcripts and folder trust by working directory. The daemon copies a session's transcript under the new folder before --resume and presses the trust and external-imports dialogs a new folder opens. If it still fails: /new in the chat starts a fresh session; tmux -L angelia attach -t <name> shows a pane that is stuck on a dialog. Treat a folder move as a migration, not a rename.

"the session never reached a prompt". A tui pane sat on a dialog for 90 s. The daemon keeps the screen it saw in ~/.angelia/tui/<session>/screen.txt and logs that path. Attach to the pane and answer it once; the next launch is clean.

"This chat's agent was not started: ...". The launch check refused. "Its profile was never compiled": run angelia compile <profile> --write once. "N of its protections were changed outside Angelia": a deny rule the last compile wrote is gone from .claude/settings.json, or the sandbox was turned off; angelia compile <profile> --write restores them, and git diff in the workspace shows who took them out. A settings file that no longer parses is named too: fix it by hand, then compile.

WhatsApp: logged out. The phone removed the linked device. angelia unpair (forgets the credentials), then angelia pair and scan the QR again.

WhatsApp: conflict (440). Another client holds this session (a second Angelia, or another client linked with the same credentials). Stop the other one; the daemon backs off and reconnects by itself.

Telegram: nothing arrives from a group. Make the bot a group admin: Telegram's privacy mode hides plain @mentions from bots otherwise.

angelia status says telegram: conflict. Another process is polling the same bot token (a second daemon, a test script, the same token on another machine). Stop it; Angelia polls again by itself within a minute and logs "telegram: polling again".

telegram: stopped: ... (401). Telegram no longer accepts the bot token: it was revoked or replaced in @BotFather. Put the new token in ~/.angelia/env, then /restart. WhatsApp keeps working meanwhile.

/restart says the routing table does not load. Fix what angelia check-config names, then /restart again. Nothing was stopped.

angelia restart refuses: "running as the agent of ...". You are inside a routed chat's agent. Restart from a terminal of your own, or send /restart in the chat: the router runs it detached so stopping the daemon cannot kill the restart.

"another angelia daemon is running (pid N)". angelia status shows it. If the pid is dead the file is stale: rm ~/.angelia/daemon.pid, then start again.

A job timer says the job changed since it was installed. The plist pins a hash of the job. angelia jobs install <profile> takes the new definition; angelia jobs lists what is out of step.

Usage limit. The chat hears the CLI's own limit line plus how to switch: /model sonnet for this session only; /model default once the limit resets.

Backup and move

The instance is one folder. ~/.angelia/workspace is a git repo; push it to a private remote (angelia init offers one). The rest (env, wa/, sessions.json) is credentials and state: copy the folder as a whole to move to another Mac, never into git.

Cutting a release

The release key signs tags and nothing else. Keep it off the machine that runs Angelia, behind a passphrase or a hardware key, so no agent there can use it.

ssh-keygen -t ed25519 -f ~/.ssh/angelia-release -C "angelia release"      # once; set a passphrase
printf '%s namespaces="git" %s\n' "<login>@users.noreply.github.com" "$(cat ~/.ssh/angelia-release.pub)" > allowed_signers
# paste the same line between the quotes of SIGNERS='' in install.sh; npm test checks the two match
git -c gpg.format=ssh -c user.signingkey=~/.ssh/angelia-release.pub tag -s vX.Y.Z -m "Angelia X.Y.Z"
git push origin vX.Y.Z
git show vX.Y.Z:install.sh > /tmp/install.sh                                # the tag's own copy
gh release create vX.Y.Z --verify-tag --title "Angelia X.Y.Z" --notes "..." /tmp/install.sh

The site's /install redirects to the install.sh attached to the release named in package.json (GitHub counts its downloads), so bump the version, cut the release, then push the site.

Every installed copy checks the next release against the allowed_signers it was installed with, so a new key reaches users only through a release signed with the old one that ships both.

Releasing without anyone at the keyboard

.github/workflows/release.yml signs and publishes a release with a second key that lives only in the repository's release environment on GitHub. Set up once: an environment named release with a wait timer (60 minutes here) and only main allowed to deploy; two environment secrets, RELEASE_SIGNING_KEY (an ed25519 key without a passphrase, its public half in allowed_signers) and RELEASE_DEPLOY_KEY (a write deploy key); and deploy keys allowed to bypass the rulesets that guard main and v* tags. Then, for each release:

git push origin <commit>:refs/heads/release/X.Y.Z     # the release commit, on top of main
gh workflow run release.yml -f version=X.Y.Z -f commit=<full commit id>
gh run cancel <run id>                                 # to stop it during the wait

The workflow checks the commit (on top of main, version in package.json, tag still free), runs the whole suite on macOS, waits, then signs the tag, pushes main and the tag together, and makes the GitHub release with the tag's install.sh and the commit message as notes, then publishes the same tarball install.sh builds to npm as angelia-gateway (trusted publishing: no npm token is stored; the package's npm settings trust this repository's release.yml in the release environment). If that last step fails, the release is out without npm: publish that tag's tarball by hand (npm ci --ignore-scripts && npm run build && npm pack --ignore-scripts, then npm publish <tgz>). The tarball holds no native binaries (sharp and the other platform packages are installed on the user's machine), so it does not matter which OS builds it. The very first publish was by hand, since npm lets a package trust a workflow only once the package exists. Know what it trades: whoever can push to main and start workflows can release, so the signature says the release came from this repository's workflow, not that a person approved it. The wait is the time to stop it.

Uninstall

angelia unpair               # first: logs the linked device out, so the phone stops listing it
angelia service uninstall    # stops the daemon and removes the LaunchAgent
angelia jobs remove <profile>   # per profile, or delete ~/Library/LaunchAgents/angelia.job.*.plist
tmux -L angelia kill-server  # the tmux-hosted sessions, if any profile used tui: true
npm rm -g angelia-gateway
rm -rf ~/.angelia            # the instance, including the WhatsApp login

Also, by hand: a profile folder you pointed outside ~/.angelia stays where it is (its .claude/settings.json and .claude/angelia-compiled.json are Angelia's), and the bot itself lives on in Telegram until you send /deletebot to @BotFather.