getbotons 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/installer/admin.mjs +6 -0
  4. package/installer/botons.mjs +430 -0
  5. package/installer/brain-clis.mjs +28 -0
  6. package/installer/linger.mjs +39 -0
  7. package/installer/output.mjs +27 -0
  8. package/installer/revision.mjs +30 -0
  9. package/installer/shell-path.mjs +66 -0
  10. package/installer/source-lock.json +2891 -0
  11. package/package.json +26 -4
  12. package/payload/LICENSE +21 -0
  13. package/payload/SECURITY.md +33 -0
  14. package/payload/installer/admin.mjs +6 -0
  15. package/payload/installer/botons.mjs +430 -0
  16. package/payload/installer/brain-clis.mjs +28 -0
  17. package/payload/installer/linger.mjs +39 -0
  18. package/payload/installer/output.mjs +27 -0
  19. package/payload/installer/revision.mjs +30 -0
  20. package/payload/installer/shell-path.mjs +66 -0
  21. package/payload/installer/source-lock.json +2891 -0
  22. package/payload/npm-shrinkwrap.json +51 -0
  23. package/payload/package.json +13 -0
  24. package/payload/scripts/backup.ts +47 -0
  25. package/payload/scripts/botons-backup.service +15 -0
  26. package/payload/scripts/botons-backup.timer +11 -0
  27. package/payload/scripts/botons.service +36 -0
  28. package/payload/scripts/check-browser.ts +90 -0
  29. package/payload/scripts/doctor.ts +790 -0
  30. package/payload/scripts/install-backup-timer.ts +52 -0
  31. package/payload/scripts/install-service.ts +206 -0
  32. package/payload/scripts/login.ts +17 -0
  33. package/payload/scripts/migrate-names.ts +47 -0
  34. package/payload/scripts/pair.ts +163 -0
  35. package/payload/scripts/restore.ts +108 -0
  36. package/payload/server/bin/botons +4 -0
  37. package/payload/server/bin/legacy/minion +4 -0
  38. package/payload/server/bin/legacy/tots +4 -0
  39. package/payload/server/src/acting.ts +16 -0
  40. package/payload/server/src/api-keys.ts +544 -0
  41. package/payload/server/src/api.ts +1484 -0
  42. package/payload/server/src/app.ts +546 -0
  43. package/payload/server/src/approvals.ts +873 -0
  44. package/payload/server/src/ask-rules.ts +181 -0
  45. package/payload/server/src/audit.ts +158 -0
  46. package/payload/server/src/auth.ts +497 -0
  47. package/payload/server/src/backup.ts +382 -0
  48. package/payload/server/src/brain-login.ts +75 -0
  49. package/payload/server/src/browser-cert.ts +67 -0
  50. package/payload/server/src/browser-handoff.ts +119 -0
  51. package/payload/server/src/browser-handover.ts +175 -0
  52. package/payload/server/src/browser-policy.ts +210 -0
  53. package/payload/server/src/browser-proxy.ts +580 -0
  54. package/payload/server/src/browser-request.ts +335 -0
  55. package/payload/server/src/browser-screenshot.ts +56 -0
  56. package/payload/server/src/browser-send.ts +155 -0
  57. package/payload/server/src/browser.ts +2164 -0
  58. package/payload/server/src/callback.ts +218 -0
  59. package/payload/server/src/capabilities.ts +90 -0
  60. package/payload/server/src/cards.ts +243 -0
  61. package/payload/server/src/cli/approve-mcp.ts +119 -0
  62. package/payload/server/src/cli/botons.ts +629 -0
  63. package/payload/server/src/cli/browser-mcp.ts +168 -0
  64. package/payload/server/src/cli/workspace-file.ts +78 -0
  65. package/payload/server/src/cli-homes.ts +38 -0
  66. package/payload/server/src/cli-install.ts +104 -0
  67. package/payload/server/src/cli-updates.ts +112 -0
  68. package/payload/server/src/codex-config-repair.ts +80 -0
  69. package/payload/server/src/config.ts +473 -0
  70. package/payload/server/src/connector-access.ts +158 -0
  71. package/payload/server/src/connector-launch.ts +203 -0
  72. package/payload/server/src/connectors.ts +947 -0
  73. package/payload/server/src/disk-usage.ts +62 -0
  74. package/payload/server/src/engine.ts +2328 -0
  75. package/payload/server/src/env-init.ts +5 -0
  76. package/payload/server/src/events.ts +46 -0
  77. package/payload/server/src/files.ts +528 -0
  78. package/payload/server/src/github-access.ts +52 -0
  79. package/payload/server/src/health.ts +117 -0
  80. package/payload/server/src/held-dir.ts +96 -0
  81. package/payload/server/src/hooks.ts +141 -0
  82. package/payload/server/src/http.ts +207 -0
  83. package/payload/server/src/index.ts +139 -0
  84. package/payload/server/src/install-type.ts +11 -0
  85. package/payload/server/src/instructions.ts +204 -0
  86. package/payload/server/src/isolation.ts +234 -0
  87. package/payload/server/src/legacy-upgrade.ts +668 -0
  88. package/payload/server/src/limits.ts +44 -0
  89. package/payload/server/src/lock.ts +75 -0
  90. package/payload/server/src/log.ts +137 -0
  91. package/payload/server/src/managed-clis.ts +65 -0
  92. package/payload/server/src/mcp-client.ts +229 -0
  93. package/payload/server/src/notify.ts +193 -0
  94. package/payload/server/src/oauth-connections.ts +650 -0
  95. package/payload/server/src/oauth.ts +311 -0
  96. package/payload/server/src/outbound.ts +142 -0
  97. package/payload/server/src/portable.ts +1669 -0
  98. package/payload/server/src/prompt.ts +676 -0
  99. package/payload/server/src/providers/api-brain.ts +612 -0
  100. package/payload/server/src/providers/api-history.ts +131 -0
  101. package/payload/server/src/providers/api-tools.ts +481 -0
  102. package/payload/server/src/providers/api-wire.ts +690 -0
  103. package/payload/server/src/providers/claude-cli.ts +620 -0
  104. package/payload/server/src/providers/cli-check.ts +265 -0
  105. package/payload/server/src/providers/cli-turn.ts +20 -0
  106. package/payload/server/src/providers/codex-cli.ts +552 -0
  107. package/payload/server/src/providers/codex-workspace.ts +61 -0
  108. package/payload/server/src/providers/failures.ts +143 -0
  109. package/payload/server/src/providers/index.ts +131 -0
  110. package/payload/server/src/providers/mcp-stdio.ts +147 -0
  111. package/payload/server/src/providers/mock.ts +94 -0
  112. package/payload/server/src/providers/process.ts +268 -0
  113. package/payload/server/src/providers/readiness.ts +43 -0
  114. package/payload/server/src/providers/sse.ts +73 -0
  115. package/payload/server/src/providers/types.ts +173 -0
  116. package/payload/server/src/rates.ts +128 -0
  117. package/payload/server/src/reactions.ts +266 -0
  118. package/payload/server/src/recent-logs.ts +489 -0
  119. package/payload/server/src/rehearsal.ts +593 -0
  120. package/payload/server/src/rehearsals.ts +303 -0
  121. package/payload/server/src/replay.ts +165 -0
  122. package/payload/server/src/sandbox.ts +767 -0
  123. package/payload/server/src/schedule.ts +214 -0
  124. package/payload/server/src/scheduler.ts +24 -0
  125. package/payload/server/src/schema.ts +882 -0
  126. package/payload/server/src/secrets.ts +46 -0
  127. package/payload/server/src/setup.ts +356 -0
  128. package/payload/server/src/skill-packages.ts +363 -0
  129. package/payload/server/src/skills.ts +527 -0
  130. package/payload/server/src/spend.ts +255 -0
  131. package/payload/server/src/ssrf.ts +126 -0
  132. package/payload/server/src/starter-gate.ts +168 -0
  133. package/payload/server/src/starters.ts +610 -0
  134. package/payload/server/src/storage.ts +32 -0
  135. package/payload/server/src/store.ts +3087 -0
  136. package/payload/server/src/tailnet.ts +132 -0
  137. package/payload/server/src/templates.ts +454 -0
  138. package/payload/server/src/trash.ts +772 -0
  139. package/payload/server/src/util.ts +97 -0
  140. package/payload/server/src/webhooks.ts +216 -0
  141. package/payload/server/src/work-feed.ts +473 -0
  142. package/payload/shared/app-version.d.mts +1 -0
  143. package/payload/shared/app-version.mjs +3 -0
  144. package/payload/shared/boton.ts +323 -0
  145. package/payload/shared/brand.ts +107 -0
  146. package/payload/shared/cards.ts +427 -0
  147. package/payload/shared/effort.ts +91 -0
  148. package/payload/shared/files.ts +104 -0
  149. package/payload/shared/handoff.ts +97 -0
  150. package/payload/shared/install-commands.ts +7 -0
  151. package/payload/shared/legacy-names.d.mts +35 -0
  152. package/payload/shared/legacy-names.mjs +244 -0
  153. package/payload/shared/live-view.ts +94 -0
  154. package/payload/shared/look-critter.ts +189 -0
  155. package/payload/shared/look-tater.ts +127 -0
  156. package/payload/shared/look.ts +200 -0
  157. package/payload/shared/reactions.ts +43 -0
  158. package/payload/shared/seeded.ts +34 -0
  159. package/payload/shared/services.ts +90 -0
  160. package/payload/shared/types.ts +3016 -0
  161. package/payload/shared/util.ts +68 -0
  162. package/payload/shared/versions.ts +44 -0
  163. package/payload/shared/wake.ts +164 -0
  164. package/payload/web/dist/apple-touch-icon.png +0 -0
  165. package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
  166. package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
  167. package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
  168. package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
  169. package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
  170. package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
  171. package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
  172. package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
  173. package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
  174. package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
  175. package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
  176. package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
  177. package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
  178. package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
  179. package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
  180. package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
  181. package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
  183. package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
  184. package/payload/web/dist/favicon.svg +1 -0
  185. package/payload/web/dist/icon-192.png +0 -0
  186. package/payload/web/dist/icon-512.png +0 -0
  187. package/payload/web/dist/icon.svg +1 -0
  188. package/payload/web/dist/index.html +67 -0
  189. package/payload/web/dist/manifest.webmanifest +12 -0
  190. package/shared/legacy-names.d.mts +35 -0
  191. package/shared/legacy-names.mjs +244 -0
package/README.md CHANGED
@@ -1,3 +1,506 @@
1
- # Temporary Holding Version
1
+ <img src="web/public/favicon.svg" width="64" height="64" alt="">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # Botons
4
+
5
+ Botons is a self-hosted home for AI agents that runs your own Claude Code or Codex.
6
+
7
+ ## Quick start
8
+
9
+ You need a Linux box and Node 20+. Sign in to your own Claude Code and/or Codex on that box.
10
+
11
+ ```sh
12
+ npx -y git+https://github.com/boundsj/botons.git install
13
+ ```
14
+
15
+ 1. Open the URL the installer prints.
16
+ 2. Approve the pairing code on the Linux box: `botons pair --approve <code>` (use `~/.local/bin/botons` if it is not on PATH yet).
17
+ 3. Follow setup to create your first Boton.
18
+
19
+ See [first-run setup](#first-run) and [installation options](#installing-from-a-branch-or-package) for more.
20
+
21
+ ## Installing from a branch or package
22
+
23
+ Install a release tarball on Linux x64 or arm64 (Node 20+ can launch the installer):
24
+
25
+ ```sh
26
+ npx -y file:/path/to/getbotons-<version>.tgz install
27
+ ```
28
+
29
+ Install directly from the public Git repository:
30
+
31
+ ```sh
32
+ npx -y git+https://github.com/boundsj/botons.git install
33
+ ```
34
+
35
+ npm clones the Git dependency to launch the plain JavaScript bin. There is no `prepare` lifecycle: npm does not install the workspace development dependencies before launching it. The entry runs on Node 20/22, downloads official Node 24, checks its SHA-256 against the official HTTPS checksum list, and uses that runtime to install dependencies and build the web app. Installed releases need no GitHub token. Avoid `#sha` fragments in the npx URL: Node 22 npm has reported GitFetcher/Arborist packing failures with that form. For a selected branch, tag or SHA, leave the npx URL plain and pass `install --source <git-url> --ref <ref>`; the bootstrap fetches that ref itself. No npm package has been published.
36
+
37
+ npx can reuse a cached installer even after the Git branch changes. Check the startup line: it prints the running installer's commit when available and always prints its code fingerprint. A selected application `--ref` is separate from that bootstrap identity. To bypass stale npx state without deleting any cache or install, use a fresh cache directory:
38
+
39
+ ```sh
40
+ npm_config_cache="$(mktemp -d)" npx -y git+https://github.com/boundsj/botons.git install
41
+ ```
42
+
43
+ If npm fails before the startup line, the installer has not run. The fresh cache directory can be removed after the command finishes. The CI probe compares plain and commit-qualified local Git URLs with Node 22.23.3's bundled npm; it does not exercise GitHub transport.
44
+
45
+ The installer asks before changing the host, starting services, or installing missing packages with apt/dnf. It requires bubblewrap >=0.11 and runs as your ordinary user. It installs under `~/.local/share/botons`, stores Node under `node/<version>` and applications under `releases/<version>`, keeps data under `~/.botons`, and links `~/.local/bin/botons`. When that directory is missing from PATH, the installer checks the login profile and offers a marked shell configuration block only when needed. Reload the login shell (`exec $SHELL -l`) or open a new terminal; until then use `~/.local/bin/botons`. Uninstall removes only the block the installer added. `botons doctor`, `botons backup`, `botons restore` and `botons pair` use the service environment. Sign in to your agent CLI yourself, open the app, and approve its displayed code with `botons pair --approve <code>`.
46
+
47
+ `install`, `repair` and `upgrade` can be run again. Upgrade backs up an existing database first. Choose a new source explicitly with `botons upgrade --source /path/to/getbotons-<version>.tgz` (a source directory, HTTPS tarball or Git URL is also accepted; Git accepts `--ref`). The chosen source is saved for subsequent upgrades; an unpublished registry release is never assumed. Failed health checks switch the code back; a schema upgrade requires restoring the backup before old code can serve again. The installer never restores data automatically. Existing managed installs under the earlier product name are refused; follow the upgrade instructions below.
48
+
49
+ Options: `--yes` consents to installation and package/service changes; `--dry-run` prints destinations without changing anything; `--home <data>` chooses the data folder; `--host <address>` chooses the bind address; `--public-url <https-url>` persists both `BOTONS_PUBLIC_URL` and `BOTONS_OAUTH_PUBLIC_URL` to the same HTTPS origin for the app and remote OAuth connector callbacks (also on repair, after consent); `--no-sudo` refuses missing system dependencies and prints any privileged linger fix instead of running sudo. Linger is checked first; an access-denied failure offers the exact sudo command after consent. Linger failure leaves the service enabled and running with a warning and recovery command. Linger is included in --yes; without it, linger asks separately. `--no-service` is for disposable test environments: it installs files without creating or starting services, timers or running doctor. Start it explicitly with `botons serve`. `uninstall` stops the service and removes managed program files while keeping data and configuration; `--purge` additionally deletes data only after a separate interactive confirmation, even with `--yes`.
50
+
51
+ Maintainers generate the publishable package with `npm run release:package`, then `npm pack ./release-package`. The artifact version comes from the root `package.json`. Git sources use the package version plus the checked out SHA, so updating a branch produces a distinct release. For example, `botons upgrade --source git+https://github.com/boundsj/botons.git --ref main` saves that source for the next `botons upgrade`. `BOTONS_PACKAGE_NAME` overrides the default `getbotons` name. The generated package contains prebuilt web files, server source, and a production dependency lockfile. Publication is a separate owner action.
52
+
53
+ ## Development
54
+
55
+ For development, use the checkout commands below. The existing `npm run deploy` flow remains available.
56
+
57
+ Requires Node 24+ and at least one of the `claude` or `codex` CLIs, logged in. Codex requires a stable 0.166.0 or newer; Botons checks the version before running turns.
58
+
59
+ ```sh
60
+ npm install
61
+ npm start # build the web app and serve on :7777 (localhost and this machine's Tailscale address)
62
+ npm run dev # web on 127.0.0.1:5173, API on 127.0.0.1:7778, data in ./.botons-dev, mock provider; prints how to pair
63
+ npm test # the fast tests; npm run test:full runs all of them (mock provider and fake CLIs, never the real ones)
64
+ npm run typecheck && npm run build
65
+ npm run check:public # nothing private or secret in the repo (see AGENTS.md)
66
+ ```
67
+
68
+ A bad setting, or a database at another schema version, stops the server with exit status 78. Only one server can use a data folder at a time.
69
+
70
+ `npm run doctor` checks an install and says what to fix: Node and the build, every setting, who can reach the server, the data folder and database, routine runs to check, backups, the running server, the agent CLIs and their logins (naming the Botons a missing or signed-out one leaves without answers), usage limits in force, the sandbox and the service. It only reads, and exits 1 if something must be fixed. Coding agents setting Botons up for you follow `.claude/skills/install-botons/SKILL.md`, which stops for your go-ahead before anything that exposes the server, changes the host or creates a credential.
71
+
72
+ ## Run it with Docker
73
+
74
+ ```sh
75
+ cp .env.example .env && docker compose up -d --build # on 127.0.0.1:7777, data in volumes
76
+ docker compose exec -it botons botons login claude # or: botons login codex
77
+ docker compose exec botons botons pair --approve <code> # the code the web app shows
78
+ ```
79
+
80
+ `docs/DOCKER.md` covers health checks, backup and restore, upgrades, the sandbox, and reaching it from other devices over HTTPS (Tailscale on the host or as a sidecar, or a public domain with Caddy).
81
+
82
+ ## Run it as a service
83
+
84
+ The systemd user service runs a release folder built by `npm run deploy`, not your checkout:
85
+
86
+ ```sh
87
+ npm run deploy -- --no-restart # build the first release; ~/.local/share/botons/current points at it
88
+ npm run service:install -- --app ~/.local/share/botons/current
89
+ npm run backup:install -- --app ~/.local/share/botons/current
90
+ systemctl --user daemon-reload
91
+ systemctl --user enable --now botons botons-backup.timer
92
+ loginctl enable-linger $USER # once: start at boot, before you log in
93
+ ```
94
+
95
+ `service:install` writes `~/.config/systemd/user/botons.service` (`--name`, `--dir` and `--env-path` change that) and, if there is none, the env file `~/.config/botons/env`: a `PATH` with node and the CLIs, and `BOTONS_CLAUDE_BIN` and `BOTONS_CODEX_BIN`. systemd does not read your shell profile, so settings go in that file; then `systemctl --user restart botons`. Run `service:install` again after changing the Node version, the app folder or `BOTONS_DRAIN_MS` (the unit's stop timeout follows it). The unit restarts the server after a failure, but not after exit 78.
96
+
97
+ Logs are one JSON object per line: `journalctl --user -u botons -t botons -o cat | jq .`. `GET /api/health` answers 200 when the database, the scheduler and at least one CLI work, else 503 with what failed. On stop, running turns get up to `BOTONS_DRAIN_MS` to finish; queued and cut-off work runs after the next start.
98
+
99
+ ### Deploy and roll back
100
+
101
+ ```sh
102
+ git fetch && npm run deploy -- origin/main # any commit-ish (default HEAD)
103
+ npm run deploy -- --rollback # back to the previous release
104
+ npm run deploy -- --rollback 1a2b3c4 # or to any release still on disk
105
+ npm run deploy -- --list # releases, newest first; * current, - previous
106
+ ```
107
+
108
+ A deploy copies the commit (`git archive`) to `~/.local/share/botons/releases/<sha>`, runs `npm ci`, typecheck, `npm run test:full` (`--skip-tests` skips it) and the build there, points `current` at it, restarts the unit and waits for `/api/health` to report the new commit. If the server does not come up, it goes back to the previous release and exits non-zero. It keeps the newest 5 releases (`--keep <n>`) plus current and previous. Other flags: `--root <dir>`, `--unit <name>`, `--port <n>`, `--env-path <file>`, `--repo <checkout>`, `--no-restart`.
109
+
110
+ ## First run
111
+
112
+ A new server has no owner. Open it (`http://localhost:7777`, or its Tailscale address): the page shows a code. **Claim** the server by approving that code on the server's machine, `npm run pair -- --approve <code>` (`docker compose exec botons botons pair --approve <code>` in Docker); the page signs in by itself. Only someone who can run commands on the server can approve, and the code alone gets nobody in, so it is safe for the page to be reachable while you do this; your own Tailscale devices can also claim it with one click. The device that claims it is recorded as the owner's first device.
113
+
114
+ The web app then walks you through setup, all of which you can change later in **Settings**:
115
+
116
+ 1. **Your name**, which Botons use when they talk about you.
117
+ 2. **Who can connect** (the access mode): this machine only, Tailscale, the local network, or the public internet over HTTPS. Botons recommends Tailscale when it is up, otherwise this machine only. The mode refuses requests from any other network before anything else runs; it never opens anything. Every mode beyond this machine lists what *you* do on the host first (sign in to Tailscale, change `BOTONS_HOST` or the published port, the firewall, an HTTPS proxy, DNS). Botons never does those, and it won't let you choose public without an `https://` `BOTONS_PUBLIC_URL` and auth on, or a mode that would lock out the device you are using.
118
+ 3. **Brains**: whether the `claude` and `codex` CLIs are installed and signed in. Signing in creates a credential, so you do it yourself on the server: `botons login claude` or `botons login codex` (prefix `docker compose exec -it botons` in Docker), then **Check again**.
119
+ 4. **Your first Boton**, with Workspace access (sandboxed) and no connectors, made your primary Boton.
120
+ 5. **Ready**: the health checks, then its chat.
121
+
122
+ Codex Workspace access needs a compatible bubblewrap helper. Codex 0.160.0 can fail while loading `AGENTS.md` with `Bad file descriptor` for a protected missing path such as `.claude`. Upgrade the CLI used by the server to stable 0.166.0 or newer, verify `codex --version` (or the `BOTONS_CODEX_BIN` executable), then open **Settings → Brains** for the reviewed upgrade action and choose **Check again**. In Docker, rebuild with a compatible Codex version. Installing bubblewrap alone does not fix this helper bug; keep Workspace sandboxing enabled. Doctor reports an incompatible CLI with the same upgrade guidance.
123
+
124
+ If Codex reports `Can’t mount on symlink destination`, run doctor and inspect its **Codex config** finding. Symlinked `CODEX_HOME/AGENTS.md` or `skills` can cause this even on Codex 0.167.0 with bubblewrap 0.12.0. In setup or **Settings → Brains**, choose **Fix it: review copy** to see the exact path, target and backup before confirming. Or run `botons doctor --fix` in an interactive terminal and type `REPAIR` for each reviewed copy. Without confirmation, Botons changes nothing. Repairs copy regular files up to 1 MiB with private permissions, keep the original symlink under `CODEX_HOME/.botons-link-backups`, and record confirmation and outcome in the audit log. Relative backup links may not resolve there; restoring the link to its original location preserves its original target text. Directory, dangling and unreadable links still need review on the server. A copy stops following updates to the target. Keep Workspace sandboxing enabled; a separate per-boton config home is not configured automatically.
125
+
126
+ A server that already had Botons when it was upgraded skips setup. Nothing in setup edits an env file or restarts the server.
127
+
128
+ ### Access modes
129
+
130
+ | Mode | Accepts requests from | Before choosing it, on the host |
131
+ | --- | --- | --- |
132
+ | `local` | this machine (and, in Docker, the published port) | nothing; from elsewhere use an SSH tunnel |
133
+ | `tailscale` | this machine and the tailnet (100.64.0.0/10, fd7a:115c:a1e0::/48) | `tailscale up`; in Docker, the Tailscale overlay and an auth key you create |
134
+ | `lan` | also private networks (10/8, 172.16/12, 192.168/16, link-local, fc00::/7) | `BOTONS_HOST=0.0.0.0` (Docker: `BOTONS_BIND`), a firewall rule for your LAN; plain HTTP |
135
+ | `public` | anywhere | DNS, an HTTPS proxy (Docker: the Caddy overlay), ports 80/443 open, `BOTONS_PUBLIC_URL=https://…` |
136
+
137
+ With no mode chosen, nothing is refused beyond what the listeners allow. The mode applies to the app's port only, not to the webhooks-only `BOTONS_HOOKS_PORT`.
138
+
139
+ **What no mode can see.** The mode checks only the address a connection comes from. Anything on this machine that forwards connections to Botons (a reverse proxy, Tailscale Serve or Funnel, an SSH or other tunnel) makes them come from this machine, so `local`, `tailscale` and `lan` let them through from wherever they started; in Docker every connection comes from the container network, so the published port and the overlays decide. Botons can't detect a forwarder it didn't set up, so the setup screen and **Settings → Access** say this for every mode, and warn when `BOTONS_PUBLIC_URL` points somewhere else. With a forwarder in front, the forwarder and the listener are the boundary, and pairing is the one after them. Locked out? `npm run pair -- --access-mode tailscale` (or `lan`, `local`, `public`, `none`) on the server's machine takes effect at once, server running or not.
140
+
141
+ ### HTTPS for the whole app
142
+
143
+ Over plain `http://` on the tailnet or a LAN, browsers mark the page "Not secure", may block or flag downloads from it as unsafe, and keep some features (installing it as an app, notifications) from it. Serving the whole app over HTTPS fixes that. Each of these changes how devices reach Botons, so it is the owner's step, done on the host; Botons never sets it up itself. For Docker, `docs/DOCKER.md` ("Reaching it from other devices") has the same choices as overlays.
144
+
145
+ **Tailscale Serve** (tailnet only, nothing public). Tailscale gets a certificate for the machine's MagicDNS name and proxies to the app:
146
+
147
+ ```sh
148
+ tailscale serve --bg --https=443 http://127.0.0.1:7777
149
+ tailscale serve status # what it serves now; `tailscale serve reset` stops all of it
150
+ # then, in ~/.config/botons/env, and: systemctl --user restart botons
151
+ BOTONS_PUBLIC_URL=https://myhost.example-tailnet.ts.net
152
+ ```
153
+
154
+ This serves every path, so it replaces a Serve set up for `/oauth` alone (the OAuth callback still works; `BOTONS_OAUTH_PUBLIC_URL` is the same origin). Through Serve, connections come from this machine, so the access mode no longer tells tailnet devices from others: Serve itself keeps it to your tailnet (see "What no mode can see"). The same goes for one-click pairing, which needs the direct Tailscale address: on `https://myhost.example-tailnet.ts.net`, pair with a code. A browser keeps the device token per origin, so a device paired on the `http://` address pairs again on the `https://` one; revoke the old entry in **Settings → Devices**. The plain address keeps working until you drop it: `BOTONS_HOST=127.0.0.1` stops the server listening on the Tailscale address (and its Tailscale lookups, so one-click pairing too).
155
+
156
+ **Caddy** (a domain of your own). Caddy gets a Let's Encrypt certificate and proxies to the app:
157
+
158
+ ```
159
+ botons.example.com {
160
+ reverse_proxy 127.0.0.1:7777 {
161
+ flush_interval -1 # /api/events is a server-sent event stream
162
+ }
163
+ }
164
+ ```
165
+
166
+ Then set `BOTONS_PUBLIC_URL=https://botons.example.com`. It needs the domain's DNS pointing at the machine and ports 80 and 443 open; reachable from the internet, it is the `public` access mode, and every device still pairs with a code. On a LAN without a public name, `tls internal` in that block makes Caddy use its own certificate authority, which each device then has to trust. Any other proxy works the same way: keep the `Host` header, don't buffer `/api/events`, allow bodies of at least 1 MB, and set `BOTONS_PUBLIC_URL` (or `BOTONS_ALLOWED_HOSTS`).
167
+
168
+ ### Files a Boton shares
169
+
170
+ A Boton's files open in its **Files** tab (and from the file chips in its chat): images, SVG, PDFs, Markdown and text show there; anything else, or anything over 25 MB, shows a card with its type and size, a **Download** button and why it has no preview. Files are a Boton's output, so none is ever shown as a page of the app. An SVG is shown as an image, which runs no script. An HTML page (a design mock-up, a report) opens in a sandboxed frame with its own short-lived link, which **Open in a new tab** also uses: its script runs, in an opaque origin, so it can't read the app's storage, cookies or pages, call `/api`, fetch, load resources from anywhere, submit forms, open windows or navigate the app's page. That also means a page shows only what it carries inline (styles, scripts, `data:` images and fonts): a font or script it would fetch from a CDN, or an image next to it in the workspace, doesn't load. A preview link opens only that one file, for an hour, and stops working when its device is revoked.
171
+
172
+ The sandbox is not network isolation. A page's script can still send what it holds (its own content, and its preview link, which it can read off its URL) by loading another address in its own frame or tab (the sandbox and CSP don't stop that), or over WebRTC. In the viewer, Botons closes the frame as soon as it loads a second page and shows the no-preview card, but the request has already gone; in a tab of its own nothing closes it. So what leaves is at most that one file and an hour-long link to it, never the device token, the device's files link or another file; but treat anything in a previewed page as something it could send. If the file is deleted, grows past 25 MB or its link expires after it was opened, the frame shows the no-preview card with why.
173
+
174
+ ### Deleting files, the trash and the inbox
175
+
176
+ In a Boton's **Files** tab, each row's **⋯** menu has **Delete**, and **Select** picks several files and folders to delete at once. A single file goes at once, with **Undo** in the notice; a folder, or several items, asks first. Nothing is deleted outright: it moves to the server's trash (`trash/` in `BOTONS_HOME`, with where it was and whose it is), and the trash button in Files lists that Boton's items with **Restore** (back where it was, as `name-2.ext` if that name is taken now, its folders made again if they are gone; where one can't be, as a file is in the way, in the nearest folder on the way, and Restore says the original folder is gone and where it went) and **Delete forever**. A restore never replaces anything, even a file a Boton puts at that name at the same moment: a file goes back by a hard link and a folder onto an empty folder it claims first, each of which fails if the name is taken; on a disk without hard links a file stays in the trash, with that reason. Each item is deleted for good 30 days after it went in; an item whose record (`meta.json`) can't be read, or whose deletion time is missing, not an exact time or in the future, is never deleted by that cleanup, only left and logged. A file a Boton swaps in while it is being deleted stays in the trash, as what it is, and the delete is refused: it is never renamed back over the name. The workspace itself and its `inbox` folder can't be deleted, and a path never leads out of the workspace: a symlink goes to the trash as the link, never what it points to, and a folder a Boton swaps for a link mid-delete or mid-restore changes nothing (the server works through the folder it checked, held open). That needs Linux's `/proc/self/fd`; where it isn't there, deleting to the trash, Restore, Clear inbox and the daily inbox cleanup are refused (Delete forever still works). Moving to the trash is a rename, so a workspace or shared folder on another disk than `BOTONS_HOME` (a separate mount) can't use it: the delete is refused with that reason and nothing changes; delete there with a Boton's own tools, or keep it on the same disk. Only a paired device can delete, restore or empty the trash; a Boton's own token can't, and a Boton still deletes its own files with its own tools as before, which skip the trash. Every delete, restore, Clear inbox and permanent deletion is in the audit log. The trash is not part of a backup.
177
+
178
+ Files you send a Boton land in its `inbox` folder. Once a day, inbox files untouched for 30 days go to the trash; **Settings → Storage** sets that to 7, 14, 30, 60 or 90 days, or **Off**. In the inbox, **Keep in inbox** (in the row's menu) pins a file so neither the cleanup nor **Clear inbox** moves it (up to 1,000 in one inbox: one more is refused, never made room for by dropping another; a kept file that is no longer there frees its place); **Clear inbox** moves everything else there to the trash. Only files directly in the inbox are cleaned up daily: folders in it, anything a Boton moved out of it, the rest of the workspace and the shared folder never are.
179
+
180
+ The Files tab's **Shared folder** button browses the shared folder (`shared/` in `BOTONS_HOME`, which Botons outside the Workspace sandbox read and write) the same way: delete files and folders to the trash, its own trash with **Restore** (back into the shared folder) and **Delete forever**, and download its files (they never open in the app, since any Boton can write them). The shared folder itself can't be deleted, and the daily cleanup never touches it.
181
+
182
+ The Files tab shows what the workspace, its inbox, the Boton's trash and the shared folder take; **Settings → Storage** shows it for every Boton. `npm run doctor` sums it in one line and warns past 1 GB in one inbox or 5 GB in all.
183
+
184
+ ### What each Boton may do, and the audit log
185
+
186
+ A Boton's settings show **What it can do now**: its computer access, connectors and the ones it may ask for, skills, Ask first and Always allow rules, and routines that webhooks can start (with Full computer access or not) or that send results to a callback. **Settings → Audit log** lists the consequential actions, newest first, with who did them (a device, a Boton, the server's machine or Botons itself): pairing, pairing approvals and revoking, access mode and setup, notifications and test pushes, Botons created, deleted, given Full computer access or other connectors, skills or rules, connectors a Boton got at once by asking (the connector's policy granted it), routines' webhook access and callbacks, failed runs retried, approvals decided, skills approved, installed from a package, put back, turned on or off, connector policies, MCP servers and OAuth connectors, and API keys saved, removed or tested. It keeps the newest 10,000 events and never holds a token, code, secret or message text. `GET /api/audit` returns it to paired devices.
187
+
188
+ ### Starter templates
189
+
190
+ **New Boton** offers starters: Daily brief, Repository monitor, Research on request (started by a webhook), Inbox triage, Weekly report from files, Web watcher, Researcher and Chief of staff. Each says what it needs: the brain (a brain that takes connectors, when its work reads a connected service), the access it asks for (Workspace access, for all of them), the connectors it uses, when it runs, where its output goes and what happens when a run fails. A starter's text grants nothing. Its connectors are slots you fill with your own connectors (or leave empty, if optional); with none that fits, the review says so and opens **Connect a service**. The review lists exactly what installing grants (brain, access, each connector, skills, each routine with its time and timezone), and the install is refused if any of that, or a chosen connector's state, changed since you looked.
191
+
192
+ A starter's routines always start paused. After installing, the **Setup check** reads what the server saved at the install (the starter and your slot choices), the Boton's settings, the brain's and connectors' last known state (it starts no check of its own, so a brain not checked lately is said to be unknown), the routines' schedules and their latest test run; it runs nothing and reaches no connector or service, and says what it can't verify. **Run a test** is a real turn on the Boton's brain, told to act as a dry run (read and check, do nothing hard to undo, say what it would have done); nothing enforces that, so it can use every tool it has. It pushes nothing and sends no callback, and a webhook routine's test is held to Workspace access without connectors. A test that finishes without an error shows the brain ran it, not that its reply is right: read it.
193
+
194
+ Only you turn a starter's routine on, from its setup (reopened later with **Set up to turn on** in the Routines panel): after a test run that finished on exactly the settings it has now (the Boton's, the routine's, its connectors' and skills'; an edit since, or a test still running, means test again), or with **Turn it on without a test run that passed**, which the audit log records as an override. Until then nothing else turns it on: not Resume, not the Boton itself (`botons routine update --resume`), not a copy of the Boton. Once on, it is an ordinary routine. The webhook's URL and key are made at install and shown only in the routine's settings.
195
+
196
+ ### Skill packages
197
+
198
+ Skills are procedures in the library (the sidebar's **Skills**). Besides writing one there, you can install one from a package: put each package in its own folder in `~/.botons/skill-packages` (`$BOTONS_HOME/skill-packages`), holding a `SKILL.md` with `name:` (the folder's name), `description:` and an optional `version:` in its frontmatter, then the procedure. **Skills → Install from a package** lists the folders with where each stands (not installed, up to date, update available, changed here since installing, name taken, or why it can't be installed), and **Review** shows exactly what installing changes: the description and procedure line by line against the installed skill, and its package details (the `version:`, every other field and the other files in the folder) against those it was installed with, what it asks for or points at (fields such as `allowed-tools`, the sites it links to, the `botons` commands it runs, with what's new in this version marked), the other files in the folder, and which Botons get it. A package is up to date only while all of that is as you installed it: a new label, field or file alone is an update. Nothing installs until you confirm that review, and an install is refused if the package, the skill, what it asks for or who has it changed since you looked.
199
+
200
+ Botons reads only `SKILL.md`, as text, and never runs, unpacks or follows anything in a package. It opens the packages folder and each package folder once and reads what is in them through those open folders (Linux's `/proc/self/fd`), so a folder renamed or swapped for a link while it is read changes nothing; on a system without `/proc/self/fd` packages are listed but can't be installed. A package folder or `SKILL.md` that is a link, a `SKILL.md` with other hard links, over 64 KB or not UTF-8, and archives are refused, and other files are listed but not installed. A skill grants nothing: what a Boton can do comes from its access and connectors, and fields like `allowed-tools` are shown, never applied. Each skill's **Details** set exactly which Botons can use it (taking one from a Boton with every skill gives that Boton a list of every other skill instead), turn it off for every Boton without deleting it, and keep its last 10 texts, any of which you can put back (with the package version it came from). Installs, updates, restores, turning skills on and off and each Boton given or losing a skill are in the audit log.
201
+
202
+ ## The sidebar
203
+
204
+ How the sidebar is arranged lives on the server, so every paired device shows the same: groups in their order (each collapsed or not), each Boton in at most one group or ungrouped at its place there, the pinned Botons in their own order (a pinned Boton stays in its group too), and whether Pinned and Ungrouped are collapsed. A new Boton goes last among the ungrouped, or last in the group it is made in (`groupId`, or `group` by name, which makes the group if there is none; a Boton from a template goes to the template's group the same way). Deleting a group moves its Botons, in order, to the end of the ungrouped; it never deletes or unpins one. It is in the database, so every backup has it.
205
+
206
+ `GET /api/sidebar` returns the layout to paired devices (Boton tokens are refused), and every change answers with the whole layout and sends it to every device as a `sidebar.updated` event: `POST /api/sidebar/groups` (`name`, `index`), `PATCH` and `DELETE /api/sidebar/groups/:id` (`name`, `collapsed`), `POST /api/sidebar/groups/:id/move` (`index`), `POST /api/sidebar/bots/:id/move` (`groupId`, null for ungrouped, and `index`), `POST /api/sidebar/bots/:id/pin` (`pinned`, `index`) and `PATCH /api/sidebar/sections/pinned` or `/ungrouped` (`collapsed`). An `index` is the place in the list counted once the Boton or group is there (0 is first; missing or past the end is last). Group names are unique in any case (Unicode case and accents composed or not: "Équipe" and "équipe" are one group), up to 40 characters, and there are at most 100 groups. A settings form sends a Boton's group or pin only when the owner changed it there, so saving it never undoes a move made elsewhere.
207
+
208
+ In the web app, **New group** at the end of the sidebar makes a group; a group's menu (the … next to its name) makes a Boton in it, renames it, moves it up or down, or deletes it. Each Boton's menu (the … on its row, always shown on touch screens) pins or unpins it, moves it up or down, or moves it to another group or a new one; a pinned Boton's menu in Pinned moves it left or right. The menus work with the keyboard (arrow keys, Home, End, Escape). Clicking a section's name collapses or expands it; a collapsed section still shows how many of its Botons have unread messages, wait for an approval or are working, and search (Enter opens the first match) finds Botons in it. A Boton's settings pick its group from the groups there.
209
+
210
+ `GET /api/sidebar/export` gives the layout as a file of its own (`format` `botons-sidebar`, `version` 1; groups by name, Botons by id), apart from any backup, and `POST /api/sidebar/import` puts one back: all of it, or nothing if anything in the file is wrong. A group with the same name keeps its id and the others are made; groups the file lacks are deleted (never their Botons). A Boton id with no Boton here is skipped and listed in `skipped`; a Boton the file doesn't mention keeps its pin and goes last among the ungrouped.
211
+
212
+ Upgrading to schema 27 makes each group name in use a group (names that differ only in case, in any script, become one), ordered by name, with each group's Botons and the pinned ones ordered by latest activity, as the sidebar showed them.
213
+
214
+ ## Reactions
215
+
216
+ You react to a Boton's messages with an emoji, and a Boton reacts to your own messages in its chat. In the web app, hover or focus a Boton's message and choose its smiley button (on a phone it is always there, or long-press the message): six quick reactions, and **More…** for a searchable set or any emoji you type or paste. Choose one you've given again, or its chip under the message, to take it off. **Settings → Reactions** picks which emoji ask for a follow-up, or turns follow-ups off. Each reaction is stored with its message (who, which emoji, when), sent to every paired device as a `message.updated` event, and is in every backup. A reaction is feedback only: it never allows or denies an approval or answers a card (approval and card events take no reactions at all), and it pushes nothing.
217
+
218
+ - **Yours:** `POST /api/bots/:id/messages/:messageId/reactions` (`emoji`) on one of the Boton's messages, and `DELETE /api/bots/:id/messages/:messageId/reactions/:emoji` to take it off; up to 10 different emoji on a message. A reaction doesn't start a turn: the Boton is given it as feedback in its next chat turn, once. The emoji in **Settings** `reactionFollowUps` (default 👎 alone; `[]` for none) also start a short follow-up turn about that message, once per message and emoji: taking it off before that turn starts withdraws it, and adding it again starts none.
219
+ - **A Boton's:** `botons react <message-id> <emoji>` and `botons unreact <message-id>`, during a turn, on your own messages in its chat only (not its own, another Boton's or the server's), one emoji per message, and never from a sandboxed webhook run. Its turn's context lists the ids of the messages it answers. A turn that only reacted and replied `[quiet]` posts nothing.
220
+ - Every Boton's system prompt has the same rules for when to react (acknowledgements, thanks, finished work), when words are needed instead, and how to read your reactions; a Boton's own instructions can change how often, not that a reaction is never an approval.
221
+ - `GET /api/reactions/export` gives every reaction as a file of its own (`format` `botons-reactions`, `version` 1), apart from any backup, and `POST /api/reactions/import` puts one back: all of it, or nothing if any entry is wrong. Each message the file names gets exactly its reactions; a message that isn't here is listed in `skipped`. Imported reactions start no turn and aren't given to the Boton as new feedback.
222
+ - A new session's replay of the chat shows the reactions on the messages it gives.
223
+
224
+ ## Pairing devices
225
+
226
+ Every browser or app pairs once. Your own untagged Tailscale devices (signed in as the owner login) pair with one click at the MagicDNS URL. Anything else opens Botons and shows a code; you approve that code from a paired device (**Settings → Devices → Approve a device**) or on the server's machine, and the new device signs in by itself. A code lasts 15 minutes and is approved once.
227
+
228
+ The server can't tell who is asking: a Boton on this machine can leave out its token and look like any browser. So a code is never a credential. The device that asks keeps a secret only it holds, and only the owner's approval of its code turns that into a device token: a code seen in a chat pairs nothing. Approve only a code you are reading off the screen of the device you are pairing; a Boton can ask to pair too, so never approve a code that reached you in a chat or a message.
229
+
230
+ ```sh
231
+ npm run pair # how to pair, and the requests waiting (codes show only on the devices)
232
+ npm run pair -- --approve ABCD-EFGH # approve the device showing this code (--label "Work iPad" names it)
233
+ npm run pair -- --list # paired devices
234
+ npm run pair -- --revoke dev_abc123 # sign one out
235
+ npm run pair -- --revoke-all # sign every device out
236
+ ```
237
+
238
+ `npm run pair` works on the database directly, so it also gets you back in if every device is lost: listing, revoking and the access mode work with the server up or down, and approving needs the server up only because the device asks through it. Run it from the release folder or a checkout. It uses `BOTONS_HOME` if set, else the one in the service's env file (`--env-path <file>`), else `~/.botons`. As a last resort, `BOTONS_AUTH=off` lets every request in: pair, then remove it at once.
239
+
240
+ ## Backups
241
+
242
+ `npm run backup` (and the timer, daily at 03:30) writes `backups/<UTC time>/` in `BOTONS_HOME`: `botons.db` (a `VACUUM INTO` snapshot that passed `integrity_check`, safe while the server runs), `workspaces.tar.gz` (`workspaces/`, `shared/`, `templates/` and each Boton's browser profile in `browser/`, without its caches) and `manifest.json`. The folder is private (0700). It keeps the newest 14 backups and never deletes a file it did not make. If tar fails, the database copy is kept, no older backup is deleted and the run exits 1. `BOTONS_BACKUP_RSYNC_TO=host:path` also copies each new backup there.
243
+
244
+ The server also makes one, the same way, when it starts on a database it must upgrade to a newer schema, before changing anything; if that backup fails, it leaves the database as it was and exits 78.
245
+
246
+ To restore, stop the server and run `npm run restore` (with the service's `BOTONS_HOME`, as `npm run pair` finds it):
247
+
248
+ ```sh
249
+ systemctl --user stop botons
250
+ npm run restore -- --list # the backups, oldest first
251
+ npm run restore -- latest # what it would do; changes nothing
252
+ npm run restore -- latest --yes # or a name from --list, or a backup folder's path
253
+ systemctl --user start botons
254
+ ```
255
+
256
+ It refuses while a server uses the data folder, and deletes nothing: the current database (with its `-wal` and `-shm` files), `workspaces`, `shared`, `templates` and `browser` move to `before-restore-<time>/` in `BOTONS_HOME` first, and a restore that fails part-way (a full disk, say) puts them back and says what to do. A backup from an older schema version is upgraded at the next start; one from a newer version is refused. The same steps restore into another `BOTONS_HOME`, or onto a new machine where it does not exist yet (it is created, private): workspace paths are stored relative to it, and devices paired before the backup stay paired; the claude and codex sign-ins are not in a backup, so sign in again there. A data folder the user running it cannot write is refused before anything changes, and the server refuses to start on one (exit 78): run both as the folder's owner.
257
+
258
+ **Settings → Health and backups** shows whether the database, the scheduler and at least one brain work and whether the server is accepting work, each failure as a fixed reason with a next step (a database that is locked, damaged, read-only or on a full disk; a brain not found or not signed in) rather than its error text; how push notifications to the current ntfy server, topic and token have gone since the server started or they last changed (not set up, turned off, or the last push and, when it failed, why in a fixed word such as refused or unreachable with where to look, how many failed in a row and the last that went through, with a button to send a test: one at a time, at most 10 a minute, each in the audit log); free disk space where the data lives and the database's size (flagged below 1 GiB or 5% of the disk); and the backups: the newest one's age (flagged when it is over 2 days old, or when there is none), how many are kept, what each holds (database and workspaces, or database only when archiving failed), backups that stopped part-way, and the commands above to back up and restore. Backing up and restoring stay commands on the server: a restore needs the server stopped. A status summary to paste when asking for help lists the same checks, the app and brain versions, build and schema (each only in its expected shape, else `unknown`) without Boton names, paths, addresses or error text. `GET /api/operations` returns the same to paired devices, with no paths, ntfy server, topic, CLI output or error text; `GET /api/health` keeps its details, error text included, for paired devices. The push and disk checks are not part of `GET /api/health`, so a failing ntfy server or low disk space alone never fails a deploy's health check.
259
+
260
+ **Rehearse a restore** (in the same section) checks that a backup would restore with the running release, without stopping the server or touching the data folder: the server copies the backup's database into a private folder (`run/rehearsal/` in `BOTONS_HOME`, emptied at every start), runs SQLite's integrity check, upgrades the copy to the server's schema as a restore and the next start would, checks it again, reads the workspaces archive through (gzip's checksum included) and checks every member as the restore does (regular files and folders only, only under `workspaces/`, `shared/` and `templates/`, no `..` or absolute paths), then deletes the copy. It unpacks nothing and runs no other program; it never follows a link in a backup folder, and it stops at 8 GiB of database, 64 GiB of archive or 256 GiB unpacked, without room for the copy, or after 30 minutes. It runs in its own low-priority process with a small heap and none of the server's environment, one at a time and at most 6 an hour (`POST /api/backups/rehearse` with a backup's name or `latest`, paired devices only), each in the audit log. The result keeps only times, the backup's name, fixed words (passed, or why not: a damaged database or archive, a schema this release does not restore, an upgrade that failed, links or stray paths in the archive, a limit reached, a restart while it ran), schema numbers, file counts and the version that ran it. A rehearsal cut off by a shutdown or crash reads as interrupted. The section also shows the release, build and schema the server runs, and for each backup whether this release's restore takes it (its own schema, or the one before, upgraded at the next start), going by the backup's manifest. A rehearsal is not a restore: restoring stays the command above.
261
+
262
+ **Settings → Server events** shows known events from the server's recent info, warning and error lines, newest first, filtered by severity, Boton or text, with a next step for the failures it knows (sign a connector in again, test push, retry a run) and Copy for a bug report. It is a selection, not the log: each line's message is looked up in a fixed catalog of the messages the server writes and shown as that event's name (any other message reads as an unclassified event), and only a fixed set of details is kept when it passes its check: the server's own ids (Boton, routine, run, approval, connector), kinds and statuses, booleans, counts and durations, and an error kind (connection refused, timeout, DNS, permission, disk full and so on) in place of the error. Error text, paths, URLs, payloads and anything else a line holds are left out, never masked and shown. Events are kept in memory only (the newest 1,000, at most 512 KB), so a restart starts the view again and nothing reaches the disk or a backup. The full log, with the detail left out here, stays in the journal or the container's log. `GET /api/logs` returns the same to paired devices, 200 events at most per answer (`level`, `q`, `botons`, and `before` for older events); Boton tokens are refused.
263
+
264
+ ## Moving your configuration to another server
265
+
266
+ There are two ways to carry a Botons server elsewhere, for different jobs:
267
+
268
+ - **A full backup** (`npm run backup`, put back with `npm run restore`, both above) is the whole server: the database with every chat, memory, token, sign-in, connector secret, API key and webhook key, and the workspaces. It is the way to recover from a lost or broken machine. Those secrets are in it as they are in the database, unencrypted: keep backups on the server's own disk (the folder is private) or on storage you trust with the secrets themselves, and encrypt one before it leaves (for example `tar -C ~/.botons/backups -cz <name> | gpg --symmetric -o botons-backup.tgz.gpg`, then the reverse on the new machine before `npm run restore -- <folder> --yes`).
269
+ - **A configuration file** (**Settings → Move to another server → Export configuration**, `GET /api/config/export`) is your deliberate setup without any secret: Botons (name, title, instructions, look, brain, model, effort, access, timezone, paused, connectors, skills, Ask first and Always allow rules, browser sites, the spending cap a day on API-key turns) with their routines (each with its spending cap a run), the skills library with each skill's earlier texts, state and package source, the connectors (library entries with their command, arguments and URL, OAuth connectors by URL, each one's request policy and whether it was on), your name, timezone, primary Boton, push server and topic and the reactions that start a follow-up, your saved templates (never the built-in ones), and the sidebar's groups and pins. It never holds chats, memories, workspace files, connector env or header values, URL queries, OAuth sign-ins or tokens, webhook keys, signing secrets or callback URLs, the ntfy token, API keys, devices or Boton tokens, browser profiles, sessions, approvals, the audit log, or what API-key turns have spent (that account stays on the server and in its full backups). A connector's command line is read by its structure, and each credential in it becomes `<left out by the export>`, short ones too: the value of a flag named as one (`--token=…`, `--password -x`, the argument after `--api-key`), a `NAME=value` whose name is one (`API_KEY=…`), a header's value (`Authorization: Bearer …`), a URL's user name, password, query and #fragment (the query's names are kept in the list of what was left out), and anything key-shaped. Each argument stays one argument, and a credential's whole value goes, however many words it has; an argument holding quoted or structured text (an `sh -c` line, JSON) goes whole if any credential is in it, or if it can't be read exactly without running it. It leaves out more than it must rather than less, and names each place so you can put it back. Text you wrote, such as instructions and skills, is exported as written: the export names any that holds something shaped like a credential so you can check it before you share the file, but it can't promise to catch every secret in prose. That check covers every text in the file: instructions, routines, templates, package fields and each skill's earlier texts, named by which one and when it was replaced, never by what was found.
270
+
271
+ **Import a configuration…** in the same section reads a file and shows what it would do before anything changes: each Boton with its brain (one this server lacks is swapped for one it has, with the model and effort left out), model, effort, computer access, rules, connectors (and which of them are already set up and on here, so usable at once), skills, browser sites and routines; each skill and connector; the settings that would change; the sidebar; what in the file looks like a credential; and what you will need to do by hand. You choose there which Botons to make (one whose name is in use is left out unless you choose it), their brains, whether to keep this server's skill or connector of the same name or use the file's, whether to take the file's settings, and how to treat the sidebar. Nothing is widened by the file alone: Full computer access (and webhook runs with it) and Always allow rules come only when you tick them for that Boton. Importing makes exactly what the preview showed, in one transaction (all or nothing), and is refused if the file, your choices or this server changed since you looked. Every routine arrives off, webhooks get new URLs and keys, connectors arrive turned off without their secrets (at a new request policy, so no earlier approval applies) and OAuth ones signed out, and no token, session or sign-in is carried over. The managed browser stays the server's own: a file can't add a library connector under its name. The import is in the audit log, with each Boton made and what it was given. Then follow its list: enter connector secrets and sign in OAuth connectors in **Settings → Connectors** and turn them on, give webhook senders their new URLs, sign each brain in, enter the ntfy token, and turn on the routines you want.
272
+
273
+ The file is JSON with `format` `botons-config` and its own `version` (5), apart from the database's schema, and up to 16 MB. A server reads every version up to its own, upgrading older ones as it reads them (a version-1 file has no templates and doesn't say which reactions start a follow-up, so the importing server keeps its own; a version-2 file has no spending caps, so its Botons arrive with none, as the preview says), and refuses a file from a newer Botons, a field it doesn't know (in a look too), a missing one, bytes that aren't UTF-8, a broken character (a lone surrogate) in any text or key, a key such as `__proto__`, and a package source path that isn't one folder name. Your saved push access token is kept only when the file names the same push server (scheme, host and port); for another server it is removed, never sent there, and the same holds when you change the server in Settings. Saved templates arrive under new ids and never widen anything: Full computer access, Full access webhook runs, and connectors, skills or a brain this server won't have are left out of them, as the preview says, routine by routine. They are shown exactly when the import that made them is committed, even if the server stops part-way: it finishes putting them in place when it starts again. Spending caps arrive only on the Botons and routines an import makes, which are new here with an account of their own from $0 (what they spent where the file was made never comes with it); the preview shows each cap, whether it counts on the brain the Boton arrives with, and a model with no known price, and arriving without the file's caps is your tick alone. An import never raises, clears or restarts the caps or spending of a Boton already here, and records each Boton's caps in the audit log. Reading a file runs, fetches and opens nothing. `POST /api/config/import/preview` (`file`, `choices`) and `POST /api/config/import` (`file`, `choices`, the preview's `fingerprint`) are for paired devices only.
274
+
275
+ ## When a routine run fails or a restart cuts it off
276
+
277
+ Every run ends in the routine's **Run history** as succeeded, failed or skipped, and nothing is left looking as if it might still run:
278
+
279
+ - **Queued** runs (including webhook runs a sender got a 202 for) survive a restart and run afterwards, in order, with their trigger and payload.
280
+ - A **network error** in a scheduled or webhook run is retried by itself, `BOTONS_RUN_RETRIES` times with growing waits (the history shows when); a scheduled run's retries stop once its next slot is due. A **usage limit** holds the run, and the Boton's chat, until the reset; moving the Boton to another brain in its settings releases them to run there in a new session. Paused Botons, including a global pause, need to be resumed before work continues at reset or after a move. Botons never switches a Boton's brain by itself.
281
+ - **Settings → Brains** shows each brain's readiness from the server's own check of its CLI: **Ready** only when a check within the last 30 minutes found it installed and confirmed it signed in; **Not installed**, **Not signed in**, **Can't run** (there, but it couldn't be started or `--version` failed) or **Out of usage** (until its reset); otherwise unknown (not checked yet or lately, no answer to the check within 10 s, or a sign-in status it couldn't read), never ready. Each says why in fixed words, what to do on the server (the sign-in or install command for a service or Docker install), which Botons use it and what happens meanwhile to their work: a not-ready brain's turns are still tried and fail (a routine run isn't held), a usage limit holds them until the reset, even on a brain that is signed out or missing (said with what to fix, which the reset doesn't). The same shows above a Boton's chat when its brain isn't ready. **Check again** runs the checks anew, at most 6 times a minute per brain (past that it shows the last check), never two at once; of two answers the page keeps the newer check of the same server run, else the later request's. `GET /api/providers` carries it as `readiness` (with `?recheck=1` for check again), and `GET /api/operations` the same fixed words without the check's text.
282
+ - **Moving a Boton to another brain** (provider) ends its sessions there: a session can't move between providers. When there is one to lose (its chat's, or a routine's own), Save first shows what is kept (memories, routines, workspace files, the latest chat replayed) and what is lost (what lived only in that session), plus what the move resets (an effort level or a routine's own model the new brain lacks) and connectors that don't run there; the update goes through only with that preview's confirmation (`POST /api/bots/:id/switch-preview`, then `switchConfirmation` with the `PATCH`), and if anything it showed changed first, it asks again. The move is posted in the chat, and the first turn of the new session starts with a handoff note from Botons: what it moved from and when, and its waiting requests, runs, routines due soon and your recent decisions as they were at the move. A turn underway at the move finishes on the old brain, but the session it was on ends with the move, even if you move it back; moved back with no turn underway, a session saved there resumes. A model change on the same brain keeps the session and asks nothing. When the Boton is idle on Claude Code, and Claude Code is confirmed ready and not out of usage, the dialog also offers to ask the old chat session for a short summary first (off unless you turn it on; `switchSummary: true` with the preview's `summary.confirmation`): one more turn there, with no tools at all (every built-in and MCP tool, skill and command taken away) and a 2-minute limit, that writes down its open work, promises and IDs. A Boton on Codex isn't offered one, and the dialog says why: Codex's CLI has no way to run a turn without its shell and file tools, and a sandbox around them is not the same as none. The summary is posted in the chat with the move and handed to the new session as the old session's own notes, never as instructions; if it can't be written, or anything the preview showed changed meanwhile, nothing moves.
283
+ - A run that was **running** when the server stopped (past the drain budget, or a crash) is failed as *Interrupted by a restart*, with an error in the chat and a push unless the routine never notifies. It is not run again by itself, because it may have partly run.
284
+ - A scheduled slot that comes while the Boton is paused, at a usage limit or still busy with the previous run is **skipped**, with the reason; slots missed while the server was down fire once at the next start.
285
+ - A run still going at its **time limit** is stopped and failed as *Ran past its time limit*, marked `meta.timedOut` (with the limit), with an error in the chat and a push unless the routine never notifies. It is not retried by itself, because it may have partly run and would likely run as long again; it is listed to check, and its callback and `GET …/runs/<id>` say `timedOut: true`. Each routine's limit is `BOTONS_TURN_TIMEOUT_MS` (20 minutes) unless the routine has a shorter one of its own (**Time limit** in its editor, `timeLimitMs` in the API, `botons routine update --time-limit 10m`); the server's setting caps it, so lowering that setting lowers every longer routine limit with it. The limit counts from when the run's agent process starts: time queued behind other work or setting up connectors doesn't count, and a retry in a new session (the first attempt found its session gone) gets what is left. Each network-error retry is a new attempt with the whole limit. Chat turns have the server's limit.
286
+
287
+ A queued or running run can be **cancelled** from its row in the run history, or from Runs to check while it waits to try again (`POST /api/runs/:id/cancel`, paired devices only, in the audit log). Only that run ends: a queued one never starts (one held to try again is not tried again), and a running one has its agent process stopped while the Boton's chat and its other runs carry on. A cancelled run is failed with *Cancelled* and marked cancelled (`meta.cancelled`, with whether an attempt at it ran, a held one's earlier attempt included, and so it may have partly run); it is not retried by itself, pushes nothing, is not listed to check, and its callback and `GET …/runs/<id>` say `cancelled: true`. A run ended by the Boton's Stop or a pause is marked the same way. A run that finished before the cancel reached it keeps its own result, and so does one that reached its time limit first: it stays timed out even if the cancel came while its agent process was still exiting.
288
+
289
+ Any failed run can be **retried** from its row in the run history (`POST /api/runs/:id/retry`, paired devices only). The retry is a new run with the same trigger, payload and headers, so a webhook run stays sandboxed, and the Boton is told the earlier run failed and may have partly run. A run is retried once; to try again, retry the newer run. Retries count toward the hourly run limits and are refused while the Boton is paused. A webhook sender that asks for the first run (`GET …/runs/<id>`) sees `retriedAs`, and a redelivery of the same delivery gets the retry instead of starting another run. Retries are in the audit log.
290
+
291
+ A routine's **callback** (each finished run's result POSTed to its callback URL) is retried 3 times on errors and timeouts. A run's callback is recorded as owed in the same write that finishes the run, so one still being sent when the server stops or crashes is sent again at the next start, to the routine's callback URL then. Delivery is at-least-once: a receiver may get a result twice, and `X-Boton-Run` names the run. Results waiting to be sent are kept even past the run history's 20 runs, up to 20 per routine; a run that finishes while 20 are still waiting (a receiver that stays down while runs keep finishing) records its result as not sent instead, and it is listed to resend. A callback that failed every retry can be **sent again** from the run's row once the receiver is fixed (`POST /api/runs/:id/callback`, paired devices only, in the audit log).
292
+
293
+ **Settings → Runs to check** lists, across every Boton, the runs that failed in the last 7 days and were not retried (test runs aside), each with its error and a Retry button, the runs waiting to try again by themselves, with when, and the runs whose result never reached the callback URL, with Send it again (`GET /api/runs/attention`, paired devices only). A retried run, or a result sent, leaves the list. `npm run doctor` counts all three, and how many runs were interrupted by a restart.
294
+
295
+ ## API keys
296
+
297
+ **Settings → API keys** keeps your own Anthropic and OpenAI API keys. With a key, a Boton can run on it: pick **Claude (Anthropic API key)** or **OpenAI (API key)** as its brain (see [Botons on an API key](#botons-on-an-api-key)).
298
+
299
+ - **Where a key lives.** A saved key is kept in the database, as OAuth connector tokens and the ntfy token are, so it is in every backup and comes back with a restore. It is never shown again: the API, events, logs and the audit log say only whether there is one and where it comes from. Workspace Botons can't read the database; Full computer Botons run as the server's user, so they could. Without a saved key, Botons uses `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` from the server's environment, read at start; a saved key always takes its place. Agent CLIs get neither, from either source (they get only an allowlist of variables).
300
+ - **Changing one.** Paste a key and Save; the field clears once it is saved, and the page never stores it. Each save or removal names the version of the key the page last saw, so a page that is out of date can't overwrite a newer key (409). Remove the saved key takes off only the saved one, and says first what is used from then on: the environment's key if one is set, else none. A key that comes from the environment is removed there, followed by a restart. Removing a key here doesn't revoke it with the provider, and older backups still hold it.
301
+ - **Test key** asks the provider for the list of models the key can use (`GET https://api.anthropic.com/v1/models` or `GET https://api.openai.com/v1/models`, to those fixed addresses only, never following a redirect). It sends no prompt and generates no text, so it uses no tokens. One test at a time per provider, at most 6 a minute, each in the audit log. The result is a fixed word (works, refused, forbidden, rate limited, provider unavailable, unreachable or an unreadable answer) and, when it works, the models: their ids, plain display names and lifecycle, kept only in their expected shape (an id that could hold the key is left out). Nothing else the provider says is kept, error text included. A test is kept with the key it was of: if the key changes while it runs, the test stops and its result is never shown as the new key's.
302
+ - **Capabilities** come only from evidence. The provider's model list: Anthropic's says whether a model takes images and which effort levels it has; OpenAI's says neither. And the key's own turns: once a turn on the key finishes with a reply, a tool call or an image, that model shows chat, tool use or images as seen working (with when), until the key is replaced or removed. Anything not seen yet (realtime voice, speech-to-text and text-to-speech always, since Botons don't use them) is unknown, never guessed from a model's name, and Botons never sends a paid request just to find out.
303
+
304
+ `GET /api/api-keys`, `PUT` and `DELETE /api/api-keys/:provider` (`anthropic` or `openai`, with `generation`) and `POST /api/api-keys/:provider/test` are for paired devices only; Boton tokens get 403.
305
+
306
+ ## Botons on an API key
307
+
308
+ A Boton whose brain is **Claude (Anthropic API key)** or **OpenAI (API key)** runs on the key from Settings → API keys, with no CLI: Botons runs the agent loop itself. Each turn sends the conversation to the provider's fixed address (Anthropic's Messages API or OpenAI's Responses API, streamed), runs the tool calls the answer asks for, sends their results back, and stops when the model answers without one. Everything else works as with a CLI brain: chat, routines on a schedule or a webhook, memory, skills, `botons` commands, approvals and connectors, except **Ask first**: Botons' tool loop has no step that pauses for you, so Ask first rules don't apply to these Botons (their capabilities say so).
309
+
310
+ - **Its tools** are a shell, reading, writing and editing files, and the tools of its Botons connectors (OAuth and library ones; never a CLI's own setup). A connector's tool list (names, descriptions, input schemas) and each result (its text and its structured content, all of it) have the connector's own secrets (its saved headers and environment values, the OAuth token the turn sends) and every API key taken out before the model or anything kept sees them; a tool whose name holds one is left out. A result's images, files and links are named, never fetched or passed on. Tool definitions are bounded too (16 KB a schema, 200 KB in all). A command that is only a `botons …` command (no pipes, redirects, `;`, `&&`, `$` or backticks) runs as the CLIs run it, reaching the Botons server with the turn's token; its webhook-run and approval limits are the server's, as for every Boton.
311
+ - **Workspace access** runs every command, file read and file write in an OS sandbox ([bubblewrap](https://github.com/containers/bubblewrap) 0.11 or newer, Linux), and what it may affect is its workspace and the shared folder (none in webhook runs), nothing outside them. Its root is empty and read-only; of the host it sees only the system's own trees (`/usr`, `/etc`, `/opt` and the like), the folders on its PATH and Node's install, each through a read-only overlay (a folder another filesystem is mounted inside, such as `/snap`, is left out, since the kernel can't overlay it): no home folder, no data folder with its database and keys, no other workspace, no `/var`, and an empty `/tmp` and `/run`. The paths Workspace Botons can't read (`server/src/isolation.ts`, plus `BOTONS_PRIVATE_PATHS`) are hidden too where they fall in that view. Through an overlay a named pipe (FIFO) or socket file of another program is the overlay's own, so writing, reading or connecting to it never reaches that program, and device files don't open; and a seccomp filter refuses to make a Unix socket at all, so no socket of a desktop, ssh-agent, D-Bus, Docker or a database is reachable, by any path, link or the abstract namespace (io_uring and the kernel keyrings neither). It has its own network with only its own loopback: the host's loopback, private networks and the internet aren't there. It sees no other process, gets no file descriptor of the server's, and only a short list of environment variables (never the server's, never a key). Its `.claude` folders are read-only, and in webhook runs the instruction files too. Programs that need a Unix socket of their own (a local database server, a browser) don't run there. The workspace is your folder too: a program of yours that watches it (a file, or a named pipe you put there) sees what the Boton writes, as it is meant to. Before a Workspace Boton's first command, Botons checks the sandbox on the machine (the filter on, and a named pipe of its own outside getting nothing through the view); without bubblewrap, or where the namespaces, the overlays or the filter can't be had (not Linux, a processor other than x64 or arm64, a container that forbids them), a Workspace Boton on a key doesn't run, and its turns say why before anything is sent. Nothing falls back to a weaker sandbox. **Full computer access** runs the same tools as the server's user, outside any sandbox.
312
+ - **Bounds.** At most 40 requests to the model a turn, 16 tool calls an answer (4 at a time), 2 minutes a command (the model may ask for up to 10), 32 KB of output a command, and the Boton's time limit for the whole turn. Stop ends the request and every command it started. Botons never sends a request twice and never runs a turn again after one of its tools did something: a provider error after that fails the turn (it says so), so nothing runs twice; before that, an overloaded or unreachable provider counts as a network error, and a routine run is retried as usual.
313
+ - **Its session** is kept by the server, in `BOTONS_HOME/api-sessions` (Workspace Botons can't read it), as the provider's own items, the model's reasoning included, passed back unchanged. A request's body is at most 600 KB exactly as sent (its instructions, tool definitions and conversation, and the images the message attaches, as base64, so an image of about 400 KB is the most one message can carry): the oldest exchanges are left out first (and the reasoning bound to the full conversation with them), then the oldest tool output of the current one, each replaced by a note so every call keeps its result. When even that isn't enough (a new input too big on its own, or a turn whose own answers outgrow it), nothing is sent and the turn says what to do. That is a bound on what Botons sends, not a model's context window, which may be smaller.
314
+ - **Usage and cost.** Each reply carries its tokens and an estimated cost, as does each routine run (Usage tab too). The estimate is the tokens the provider reported times the rates in `server/src/rates.ts`, copied from the providers' price pages (the date they were checked is there); it is not the bill. A model with no rate there, or a promotional rate past its end, has no known price: its cost is shown as unknown, never guessed.
315
+ - **Spending caps** are in the Boton's settings, under Spending: a daily cap (from midnight in the Boton's time zone) and a cap a run for each routine, in US dollars of that estimate. Botons keeps one row per request (`api_requests`), counted on the day it was sent: a turn that runs past midnight counts its later requests on the new day. Before each request, in one step, the caps are read as they are then (a cap added, lowered or removed during a turn applies from its next request; one already sent stays counted) and the most it could cost (every input token at the dearest input rate, its whole answer at the output rate) is written as its row, so a crash or another turn counts it too; the request goes only if that fits in what is left, with its answer's length lowered to fit, and the turn stops (with what it did so far) when not even a short answer fits. A request whose answer broke off or was stopped, or whose usage report is missing or doesn't add up, stays counted at that most, since the provider may have billed it; one still reserved when the server starts again is held the same way. With a cap set, a model with no known price doesn't run, and neither does a capped Boton on a day (or a run) that already has a request with no known price. Only you can set caps (`GET` and `PUT /api/bots/:id/spending`, paired devices only, audited); a Boton can't see or raise its own. Caps bound Botons' estimate, not the provider's bill.
316
+ - **The key** goes only into the request's header, to the provider's fixed HTTPS address, never following a redirect. A turn uses the key in use when it starts and only that one: if it is removed or replaced while the turn runs (or the saved one goes and the environment's takes its place), the turn stops before its next request and says so; the next turn uses the new key. Every key Botons holds is taken out of everything that comes back (the model's text, drafts, a tool's output, a connector's result, the provider's errors) before it is shown, kept or sent on, and no request body is sent while it holds one. In what the session keeps, the model's text and tool arguments are redacted; signed or encrypted reasoning that holds a key can't be changed, so it is left out; a tool call whose arguments held a key is never run; and an answer with a key where nothing can be changed (an id) is refused whole. That redaction covers the keys and their common encodings, not every way a model could transform one. A key is never in a tool's environment, argv, the log or an event. `npm run check:api-brains` drives the whole of it in headless Chrome against a fake Anthropic API on loopback.
317
+
318
+ ## OAuth connectors
319
+
320
+ **Settings → Sign-in connectors** adds a remote MCP server that signs in with OAuth (a fleet gateway, say): give it a name and the server's URL, then Connect. Botons finds the server's authorization server (protected resource and authorization server metadata), registers itself there as a public client named Botons, and opens the server's sign-in page with PKCE (S256) and the server's URL as the resource. Some servers ask for a one-time code there (a gateway's operator mints one, for example); that is expected. After you approve, the browser comes back to Botons, which keeps the tokens in its database. Give the connector to Botons in their settings like any other: each turn gets an access token in its Authorization header (fresh enough to last the turn) and never the refresh token. A token can't be replaced mid-turn, so a turn longer than the token's life loses the connector part-way; tool calls are never replayed.
321
+
322
+ The authorization server sends the browser back to an HTTPS address, while the app itself stays on plain HTTP. Publish just the callback path over HTTPS, for example with Tailscale Serve, and tell Botons its origin:
323
+
324
+ ```sh
325
+ tailscale serve --bg --set-path /oauth http://127.0.0.1:7777/oauth
326
+ # then, in ~/.config/botons/env:
327
+ BOTONS_OAUTH_PUBLIC_URL=https://myhost.example-tailnet.ts.net
328
+ ```
329
+
330
+ The redirect URI is exactly `<BOTONS_OAUTH_PUBLIC_URL>/oauth/callback`; only that path is served there, and everything else still needs a paired device. Each sign-in is started from a paired device and works once, for 10 minutes. Servers on the tailnet or this machine are private addresses: allow them with `BOTONS_OAUTH_ALLOW` (plain HTTP only for loopback). Botons only connects over HTTPS, checks every address it connects to, and follows no redirects.
331
+
332
+ **Keep connected** (on by default) refreshes the sign-in weekly so an unused one doesn't lapse. Refreshes are one at a time per connector. If a refresh's answer is lost after the request went out, the refresh token is never sent again (the server may have rotated it, and servers that detect reuse revoke the whole sign-in): the connector asks to be signed in again instead. An error answer from the server is retried a little later. When the server refuses the grant, the connector shows **Reconnect** and the Botons that have it get one note in their chats.
333
+
334
+ If the server has a `work_feed` tool, Botons polls it every 30 seconds with the connector's token. When a Boton starts work with the server's `work_start`, Botons records which Boton started it, and wakes that Boton when the work finishes a turn, needs input, fails, is cancelled or its delegated work ends. Updates on work no Boton is known to have started are ignored, or go to the primary Boton if you choose. Other servers are used as plain connectors.
335
+
336
+ **Every connector is Botons' own**: a server in Settings → MCP servers (a command, or a URL with its headers) or a sign-in connector. Botons never reads the MCP servers in your own Claude Code or Codex settings, and each turn's CLI is started without them (Claude Code with `--strict-mcp-config`, Codex with `--ignore-user-config`), so a Boton has exactly the connectors you gave it, whichever brain it runs on. A connector a turn goes without (it needs signing in again, its token could not be refreshed, it was removed, or the brain can't take it) is listed with the reason under **Needs your attention** in the Boton's settings (`GET /api/bots/:id/connector-notices`), and the Boton's own turn is told too. So is one the turn's CLI was given but showed it didn't start: Claude Code reports each server's status and tools when a turn starts (failed, refused the sign-in, still starting, listed no tools), and the next turn is told it went without. Codex reports nothing about starting its servers, so on Codex a connector is only known to have started once a turn called one of its tools; until then the log calls it unconfirmed, never working.
337
+
338
+ **A command on this computer** (a stdio MCP server) starts in the Workspace sandbox's view of this computer, whichever Boton has it and whatever its access: Claude Code, Codex, or Botons itself for a Boton on an API key, starts it in bubblewrap with an empty read-only root, this computer's programs and libraries (`/usr`, `/etc`, `/opt` and the like, the folders on the server's `PATH`, Node's own install), each read-only, and the folder of its command and of each argument that is an absolute path to a file or folder. It sees no workspace, no shared folder, no data folder (`BOTONS_HOME`) and no home folder beyond those, by any path: a folder that is a second mount of one of them (a bind mount) is left out too. Its `HOME` and working folder are an empty `/tmp` of its own, so `npx` and the like download into it on every start. It keeps this computer's network, but no Unix sockets (no local program's socket), and nothing it writes changes a file on this computer. Its environment is a few of the server's variables (`PATH`, `USER`, `LANG`, ...), none of its keys or settings, plus the connector's own, and goes to bubblewrap in a private file, never on a command line. A connector whose command or arguments name something in a workspace, the shared folder, the data folder, a temporary folder or a private folder is left out of every turn, with why under **Needs your attention**; so is every local connector where bubblewrap can't run (Workspace access can't either; in a container that forbids user namespaces, say). Botons' own connectors (the managed browser) start as they are. Which Botons have a connector and the environment you give it are what it is handed, not what confines it: inside, it still runs as the server's user, with the network.
339
+
340
+ Upgrading to schema 23 takes connectors from the CLIs' own settings (ids `claude:<name>` and `codex:<name>`) off every Boton that had them, after a backup the server makes before any schema upgrade. Each one becomes a notice in that Boton's settings, with where it pointed; when a Botons connector has the same name or address, the notice offers it (**Use …**, then save). Nothing is given to a Boton until you choose it. `npm run doctor` lists the Botons with such notices, and those whose connectors can't be used.
341
+
342
+ **Test** (Settings, on a sign-in connector or a library server with a URL; `POST /api/connectors/:id/test`, paired devices only, recorded in the audit log) has Botons connect to the server itself, start an MCP session and list its tools, calling none. It goes through the same guard as sign-ins: HTTPS, or a private or loopback address in `BOTONS_OAUTH_ALLOW`; a server outside that is reported as not tested rather than failed. A tool list proves the server answers from this computer with the saved credentials, not that a Boton's turn loads it: Claude Code or Codex starts its own connection in the turn, and the Boton's settings say which connectors its last turn left out. Library servers that start a command are not tested this way (Botons doesn't run their commands outside a turn); each connector says what its status rests on. The last result is kept until the server restarts or the connector changes.
343
+
344
+ **Connect a service** (Settings) adds one step by step: a known service (GitHub: its MCP URL, a fine-grained personal access token) or how it connects (a sign-in, a URL with an API key, or a command on this computer), its details, then a test where Botons can run one and the Botons that get it. A key is typed into a masked field (Show reveals only what you typed; saved values never come back) and sent as `Authorization: Bearer …`, a bare token wrapped for you; **Advanced** takes other headers, such as `X-Api-Key`, masked the same way. Save and test lists the server's tools, or says in plain words why it couldn't ("GitHub refused the token (401). Check its permissions or expiry."), never with the key in it. **Which Botons can use it**, on each MCP server and sign-in connector, lists every Boton: the ones that have it (their turns get it), the ones that may ask for it, and the ones whose brain can't use connectors, with why a Boton's last turn went without it. Check and uncheck Botons there and save to give it to exactly those Botons, from their next turn on, or uncheck all to take it off every one (`GET` and `PUT /api/connectors/:id/access`, paired devices only). Saving sends who had it as you saw it, so if a Boton's connectors changed meanwhile nothing is saved and you review it again. Each Boton changed is its own entry in the audit log, and the connector's **History** shows those entries with the connector's setup, tests and request policy. The built-in browser is listed read-only: give it in each Boton's settings, where its sites are.
345
+
346
+ **Connectors Botons can ask for** (Settings): a connector you turn on there can be asked for with `botons connector request`; you approve each request on a card, or let it be granted at once (a run started by a webhook always needs the card).
347
+
348
+ ## The managed browser
349
+
350
+ Each Boton can have a browser of its own: a Chromium profile in `~/.botons/browser/<boton>/` (backed up with the workspaces, without caches or lock files), run by the server, never by the Boton. It comes as a connector built into Botons, `botons_browser`: check it under the Boton's **Connectors** to turn the browser on, and list the sites it may read under **Browser**. A new Boton (the first one in setup, **New Boton**, or one from a template or starter) starts with it checked and its sites set to `*`, any public site; a Boton made before keeps what it had, and its **Browser** settings ask once whether to turn web access on for all public sites. On a server without a Chromium the browser reads as off there, with why, and turns go without it. It works the same whether the Boton's brain is Claude or Codex, and a run a webhook started never gets it (webhook runs get no connectors).
351
+
352
+ **It loads pages on its own, and acts on them only through your cards.** The Boton has six browser tools: open an address, read the page (its text, its links to allowed sites, and its controls, each with a ref), go back, take a screenshot, click a control and type into a text field. Opening and reading load a page as any browser does, with GET and HEAD requests to the allowed sites; a site can act on a GET too, so that is limited to your sites, not proof that nothing changes. Clicking a link to an allowed site opens its address, as open would, without running the page's script for the click. Typing replaces the field's text and presses nothing, and is refused for passwords, codes, card numbers and other secrets (judged from what the page says of the field, so it errs towards refusing). Any other click, and any typing, marks the page as acted on: whatever the page then makes the browser send, whatever the method (a form, a GET form, a script's GET or POST), is held where it leaves Chromium and shown to you on an approval card:
353
+
354
+ - **The card is the request.** It shows the method, the full address with its query, every field or the body as sent, and the headers the page set (cookies and other credentials are named, never shown). The label of what was clicked is shown as the site's own text: a button called "Continue" can be a purchase, so nothing is ever allowed for its label. A request a card can't show in full (a file in it, bytes that aren't text, or too long) gets no card and is refused.
355
+ - **Allow once, now.** Allowing sends exactly the held request once, and only while the Boton's step waits for you (`BOTONS_APPROVAL_WAIT`). The server sends it itself, through the browser's proxy, with the headers and body it was held with and no cookie added: a request held without cookies goes without them. Right before it goes, the server checks again that it is the same turn, browser, page document, site, every byte and the browser's cookie jar the card was made for (cookies that changed meanwhile, a new sign-in say, mean it isn't sent: click again for a new card); the card is spent as it goes, so a second decision, a retry or another turn can't use it. No redirect is followed from a page acted on (a 307 or 308 would send it again); the Boton is told where it points. Denied, withdrawn, or not decided in time, it never leaves; there is no "always allow" for these cards, and a decision never carries into a later turn.
356
+ - **What became of it.** The card and the Boton's result say which: sent (the site answered), not sent (it failed before it left), or unknown (it was sent and the connection broke before the site answered, so the site may have acted on it). An unknown send is never retried: the Boton is told not to send it again and to tell you, so you can check on the site.
357
+ - **The cookies it goes with are known.** The browser's whole cookie jar is read as a click or typing starts and again once its request is held, and the two must be the same: a cookie added, changed or removed in between (by a sign-in, or the page's own script, even one that sets a cookie for that very request) means no card, and the Boton is told why. The usability cost is accepted on purpose: a page that rewrites a cookie as it submits can't be acted on this way, since nothing can tell which cookies Chromium put on the request. The request then carries no cookie at all (a script's request made without credentials) or exactly the jar's cookies for its address; two cookies of one name must be in the order the browser sends them, and two whose order can't be told apart (paths of one length) mean no card.
358
+ - **One request per card, nothing after.** Only the first request during the step is held (a form the step submits takes the place of a script's request, and a script's request that of anything else); every other request the page makes, during the step or later (a timer, a poll, an image, a beacon or keepalive as it unloads), is refused, and the browser's proxy lets nothing from Chromium through at all until the Boton opens an address or goes back, which ends that browser (Chromium and its proxy) and starts another on the same profile with the same cookies, so nothing the old page still had on its way can go.
359
+
360
+ It never signs in itself, downloads or uploads. You can sign its browser in yourself:
361
+
362
+ **Signing a Boton's browser in (a handover).** Under **Browser** in the Boton's settings, **Sign in for …** gives you its browser: you see its page and drive it with a few fixed inputs (open an address on its sites, click a point on the page, type text into whatever has focus, press Enter, Tab, Backspace, Escape or an arrow key, scroll, back, reload), sign in, and press **Done** to give it back. Closing the dialog gives it back too.
363
+
364
+ - **Yours alone while it lasts.** Taking it over closes the Boton's browser first (a card it was waiting on is withdrawn and its request never sent), and until you finish its browser tools are refused. Your browser is another Chromium, on a profile of its own made for the handover with the Boton's cookies, started for you on a blank page, never by a turn. Only the paired device that took it over gets its view and sends it input; any paired device can end it, and it ends on its own when that device is revoked, when nothing is sent for 10 minutes, or after 30 minutes. **Stop and turn off**, unchecking the connector and the server stopping end it too. A Boton's token can't reach any of it.
365
+ - **What it may send.** It loads pages from the Boton's sites only (the same proxy, the same refusal of private and loopback addresses unless a site names them). A page's writes (a sign-in form's POST, a script's request) go only to those sites and only within 10 seconds of your own click, key or typing; opening an address, going back and reloading end that moment at once, so a page's background requests at any other moment, what it sends as it unloads, and anything after you finish are refused. The proxy decides this for each request as it leaves, HTTPS included: for your browser alone it ends HTTPS itself, with a certificate made for that one browser, and checks each site's real certificate on the way on. Chromium's routing also refuses writes from a worker or another tab where it sees them; within the 10 seconds the proxy can't tell what in the browser sent a write, so it goes and is journaled. There is no input that runs a script, reads the page's DOM or cookies, or reaches Chromium's DevTools.
366
+ - **Your credentials stay yours.** What you type goes into the page and nowhere else: Botons doesn't keep, log or show it, the journal says only that you took over, opened which addresses (origin and path) and which requests the page sent, and the audit log records the start and the end with why it ended. The view is made on demand (a few frames a second at most) and kept nowhere: not in the workspace, the chat, the journal or the audit. Chromium's own password manager and autofill are off in every Boton's profile, so the profile keeps the site's cookies, not your password.
367
+ - **After.** Your browser closes and its profile is deleted, with whatever Chromium kept of the pages you filled in (form values in its session history, the history itself); one left by a server that was killed is deleted when it starts, and backups never include it. Only the cookies come back: into the Boton's profile, which keeps the cookies it had (a cookie the site removed during the handover is removed too, if the handover could read them as it started), and session cookies in the server's memory, so a sign-in that only lasts the browser's session is gone after the server restarts. If your browser dies before it can be read, the cookies as of your last input come back. The Boton's profile is backed up with the workspaces, under the same permissions.
368
+
369
+ What the server enforces, whatever the Boton or a page does:
370
+
371
+ - **Only while a turn has it.** The browser starts in a turn that was given the connector, answers only that turn's token, and closes when the turn ends. Outside a turn, only your handover opens it. Unchecking the connector or **Stop and turn off** (the kill switch) closes it at once.
372
+ - **Sites.** Every request goes through a proxy in the server that allows only the listed sites (`example.com` covers its subdomains, `host:port` names one address, `*` is any public site, for Workspace and Full computer Botons alike, and never this machine or a private network), resolves names itself and refuses private and loopback addresses (IPv6 included) unless an entry names them with their port. A Workspace Boton's list can name such an address only if `BOTONS_BROWSER_ALLOW` does. Removing a site cuts connections already open to it.
373
+ - **Reads only at the network, but for a card.** Anything but GET and HEAD (a form, a script's POST, a beacon) is refused before it leaves Chromium, and again in the proxy for plain HTTP; from a page acted on, everything is, at the routing and at the proxy, except the one request a card allowed. WebSockets never connect, service workers are blocked, and QUIC and WebRTC's direct UDP are off. Popups are closed and dialogs dismissed.
374
+ - **Chromium's sandbox is on.** In Docker, `compose.yaml` gives the container the seccomp profile that sandbox needs (see Browser in `docs/DOCKER.md`); `BOTONS_BROWSER_SANDBOX=0` turns it off, the owner's last resort where a host still can't start it. Chromium runs with its own home folder and without the server's environment.
375
+ - **Page content is data.** The page's text comes back marked as the site's, with the rule that nothing on a page is an instruction from you. There is no tool that runs the Boton's own script in a page.
376
+ - **Records.** Each step, each blocked or held request and each request sent on a card goes to the Boton's browser journal (origin and path only, never a query; Settings → Recent browser steps); the card itself keeps the full request. Giving or taking the connector, changing its sites, **Stop**, every decision on a card and each handover's start and end go to the audit log. Screenshots are saved in its workspace (`browser-screenshots/`, never through a link the Boton made) and shown in its chat.
377
+
378
+ What it does not do yet: download into or upload from its workspace, show live browser steps and screenshots as cards in the chat, let you watch a Boton's turn as it browses, and control this computer's screen, mouse or keyboard (Settings says why).
379
+
380
+ `npm run check:browser` (in Docker, `botons check-browser`) starts the browser the way a turn does, on a temp profile against a page it serves on loopback, and checks that it reads, screenshots and lets nothing else out; `doctor` checks that Chromium starts. Install Chromium with your package manager (`chromium`), or `npx playwright-core install chromium`, or point `BOTONS_BROWSER_PATH` at one.
381
+
382
+ ## Upgrading an install made under an earlier name
383
+
384
+ An install made before Botons had its current name upgrades in place, keeping every paired device, token, grant, webhook URL, OAuth sign-in, browser profile, API key, spending record and file:
385
+
386
+ - **The data folder and database.** On its first start the server backs the data folder up (into its `backups/`, as `npm run backup` does), then moves it to `~/.botons` and gives the database its current name, leaving a link at each earlier path so anything that names it keeps working. A data folder named by `BOTONS_HOME` somewhere else stays where it is; only the database is renamed. If the backup fails, nothing moves and the server keeps using the folder where it is.
387
+ - **The service.** `npm run deploy` stops the earlier unit, moves the release folder to `~/.local/share/botons`, writes `~/.config/botons/env` from the earlier env file with every setting under its `BOTONS_*` name, starts `botons.service` (and `botons-backup.timer`) and disables the earlier units. If the new service doesn't come up healthy, it starts the earlier unit again. The installer does the same for an install it made; `npm run migrate:names` does it by hand, and `npm run migrate:names -- --rollback` undoes it.
388
+ - **Settings and the command.** Settings, and the command a Boton runs, still work under their earlier names for this release; `npm run doctor` names each one still in use, as deprecated.
389
+ - **Docker.** Before the first `docker compose up` of this version, run `sh docker/legacy-names.sh` once: it stops the earlier Compose project, copies its volumes into the `botons` project's (the earlier ones stay as they were), writes `botons.env` and rewrites `.env` with current setting names, and keeps a Tailscale sidecar's name. `sh docker/legacy-names.sh --rollback` starts the earlier project again.
390
+
391
+ Agents start a new session after the upgrade, as after any change to their instructions; their memories, routines, workspace and chat history carry over.
392
+
393
+ ## Configuration
394
+
395
+ Environment variables (for the service, in `~/.config/botons/env`). Empty counts as unset; durations take `ms`, `s`, `m`, `h` or `d`.
396
+
397
+ | Variable | Default | |
398
+ | --- | --- | --- |
399
+ | `BOTONS_HOME` | `~/.botons` | Data folder: database, workspaces, shared folder, backups |
400
+ | `BOTONS_PORT` / `PORT` | `7777` | `BOTONS_PORT` wins |
401
+ | `BOTONS_HOST` | `127.0.0.1` + Tailscale IP | Comma-separated bind addresses; must include `127.0.0.1`, `0.0.0.0` or `::` |
402
+ | `BOTONS_NO_TAILSCALE` | | `1`: no `tailscale` calls (and no one-click pairing) |
403
+ | `BOTONS_TAILSCALE_POLL_MS` | `20s` | How often to look for the Tailscale address |
404
+ | `BOTONS_OWNER_LOGIN` | this machine's Tailscale login | Whose devices pair with one click |
405
+ | `BOTONS_AUTH` | `on` | `off` lets every request in without a token |
406
+ | `BOTONS_PUBLIC_URL` | MagicDNS URL | Base of the links in pushes and of webhook URLs |
407
+ | `BOTONS_ALLOWED_HOSTS` | | Extra names accepted in the `Host` header (by Vite too) |
408
+ | `BOTONS_HOOKS_PORT` | | A second port serving only webhooks (for Tailscale Funnel) |
409
+ | `BOTONS_HOOKS_HOST` | `127.0.0.1` | Where that port listens |
410
+ | `BOTONS_HOOKS_PUBLIC_URL` | | Public base of webhook links, e.g. that port's Funnel URL |
411
+ | `BOTONS_CALLBACK_ALLOW` | | Private hosts, addresses and CIDRs routine callbacks may reach |
412
+ | `BOTONS_OAUTH_PUBLIC_URL` | | HTTPS origin that reaches `/oauth/callback`, for OAuth connectors (see below) |
413
+ | `BOTONS_OAUTH_ALLOW` | | Private hosts, addresses and CIDRs OAuth connectors may reach (a tailnet server, say) |
414
+ | `BOTONS_BROWSER_PATH` | a Chromium Playwright installed, else the system's | The Chromium the managed browser runs |
415
+ | `BOTONS_BROWSER_HEADED` | | `1`: show its window, on a host with a display |
416
+ | `BOTONS_BROWSER_SANDBOX` | `1` | `0`: run Chromium without its own sandbox, where a host can't start it even with the image's seccomp profile (the container is then its only boundary) |
417
+ | `BOTONS_BROWSER_ALLOW` | | `host:port` addresses on this machine or a private network a Workspace Boton's browser may be given |
418
+ | `BOTONS_TZ` | system timezone | Default timezone for new Botons |
419
+ | `BOTONS_MAX_CONCURRENT` | `3` | Agent processes at once (1–64) |
420
+ | `BOTONS_TURN_TIMEOUT_MS` | `20m` | Turn time limit, for chat turns and routine runs; a routine may have a shorter one of its own |
421
+ | `BOTONS_APPROVAL_WAIT` | `5m` | How long an Ask first call waits for you |
422
+ | `BOTONS_SCHEDULER_MS` | `10s` | How often due routines are checked |
423
+ | `BOTONS_ROUTINE_RUNS_PER_HOUR` | `30` | Webhook and manual runs per routine per hour |
424
+ | `BOTONS_RUNS_PER_HOUR` | `100` | Webhook and manual runs per hour, all routines |
425
+ | `BOTONS_RUN_RETRIES` | `3` | Retries of a run that failed on a network error |
426
+ | `BOTONS_RUN_RETRY_DELAY` | `1m` | Wait before the first retry (then 5 and 15 times that) |
427
+ | `BOTONS_ASKS_PER_PAIR_PER_HOUR` | `20` | `botons ask`s between two Botons per hour |
428
+ | `BOTONS_ASKS_PER_HOUR` | `100` | Asks between all Botons per hour |
429
+ | `BOTONS_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error` |
430
+ | `BOTONS_SSE_PING_MS` | `25s` | `/api/events` heartbeat |
431
+ | `BOTONS_DRAIN_MS` | `2m` | On stop, how long running turns get to finish |
432
+ | `BOTONS_CLAUDE_BIN` / `BOTONS_CODEX_BIN` | `claude` / `codex` | CLI paths |
433
+ | `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | | API keys used when none is saved in **Settings → API keys** (see above). Never passed to agent CLIs; Full computer Botons can read the server's environment, so prefer saving them in Settings |
434
+ | `BOTONS_PRIVATE_PATHS` | | More paths Workspace Botons can't read, `:`-separated |
435
+ | `BOTONS_ENABLE_MOCK` | | `1` adds the `mock` provider |
436
+ | `BOTONS_CONTAINER` | | `1` (the Docker image sets it): `npm run doctor` checks for a container |
437
+ | `BOTONS_DEPLOY_ROOT` | `~/.local/share/botons` | Where `npm run deploy` keeps releases |
438
+ | `BOTONS_BACKUP_RSYNC_TO` | | Where `npm run backup` also copies each backup |
439
+ | `BOTONS_WEB_HOST` / `BOTONS_WEB_PORT` | `127.0.0.1` / `5173` | `npm run dev`'s Vite server |
440
+ | `BOTONS_API` | the dev API | Where Vite proxies `/api` and `/hooks` |
441
+ | `BOTONS_DEV_HOOKS_PORT`, `_HOST`, `_PUBLIC_URL` | | `npm run dev` drops the shell's `BOTONS_HOOKS_*`; these take their place |
442
+
443
+ ## License
444
+
445
+ MIT (`LICENSE`). Contributions: `CONTRIBUTING.md`. Security reports: `SECURITY.md`.
446
+
447
+ On a fresh installer run, choose one missing subscription CLI when prompted, or
448
+ pass `install --brain codex` (or `--brain claude`). Botons shows the exact official
449
+ npm install command and asks before running it. For an unattended fresh install,
450
+ `install --brain codex --yes` selects Codex and consents; `--yes` alone never
451
+ selects a CLI. The bundled Node runs npm into Botons' own per-CLI prefix without
452
+ sudo. Existing binaries and `BOTONS_CLAUDE_BIN` / `BOTONS_CODEX_BIN` overrides are
453
+ kept. Installation creates no login: the owner signs in during Brains setup.
454
+ The managed Claude copy has its independent updater disabled so updates remain
455
+ under owner control. This option is for fresh installs; it refuses an existing
456
+ managed server rather than changing a CLI underneath running turns.
457
+
458
+ The running server checks the publishers' npm release metadata at most daily.
459
+ Settings → Brains shows **Update available**, or **Needs update** when the
460
+ installed Codex cannot safely run turns. Failed lookups retain the last known
461
+ release and wait a day before retrying, including after a restart. Doctor reads
462
+ that same saved release information without contacting a registry or changing
463
+ anything. A checkout without a release version is not compared with published
464
+ Botons packages. Installer releases also show a newer Botons release when available;
465
+ `botons upgrade` requires the release source and never runs automatically.
466
+
467
+ For an installed server, use **Update now** in setup's Brains step or
468
+ Settings → Brains. If the CLI is missing, choose **Install CLI** instead. Review
469
+ the exact official npm command, then choose **Install and use this copy**.
470
+ Botons uses its bundled Node runtime to install into a fresh release folder under
471
+ its own prefix, even when the current CLI is a system copy requiring sudo.
472
+ The system copy stays in place. Botons waits up to a minute for active turns to
473
+ finish without interrupting them, holds queued work, checks the new CLI's
474
+ compatibility, and selects it without restarting. If turns are still active,
475
+ try again after they finish. Failed installs keep the selected CLI unchanged.
476
+ The paired owner's request and result appear in the audit log.
477
+
478
+ The effective CLI selection is saved privately in `BOTONS_HOME/managed-clis.json`
479
+ and reused on restart by the server and doctor. A later, different explicit
480
+ `BOTONS_CODEX_BIN` or `BOTONS_CLAUDE_BIN` overrides that selection. Doctor prints the
481
+ reviewed managed installation command for a CLI that needs an update; use Brains
482
+ to install and select the copy with clicks. A missing managed Node runtime needs
483
+ `botons repair` before this action is available. No sign-in is started by installing.
484
+ Automatic CLI updates are not enabled; Botons-launched Claude commands disable the
485
+ publisher's independent updater.
486
+
487
+ After a CLI update or guided sign-in, Brains refreshes readiness for the selected
488
+ copy. A compatible signed-in copy shows Ready ✓; Update now is offered only for
489
+ a missing CLI, an incompatible version, or a known newer release. The first-boton
490
+ step checks again on entry and offers Check again; if none is ready, go back to
491
+ Brains before creating it. Superseded managed UUID release folders are cleaned
492
+ after a successful update, once turns have drained, and at startup. The selected
493
+ copy, system installations, unrelated folders and symlink targets are retained.
494
+
495
+ Codex Workspace readiness includes an offline protected-filesystem probe and
496
+ session initialization in a disposable config home. It uses its own loopback stub
497
+ endpoint, no login and no model service. Settings → Brains and doctor report
498
+ failures before a Workspace turn starts; signed-in status alone is insufficient.
499
+ Some hosts hide parts of `/proc` (for example, a bind mount over `boot_id`). The
500
+ kernel can then refuse a fresh private process filesystem. Both Codex's helper
501
+ and Botons' production tool sandbox need that mount. Doctor checks the actual
502
+ production overlay, seccomp and private-proc setup separately from Claude's
503
+ namespace check. Workspace stays blocked when its helper fails. Botons never
504
+ silently disables the inner sandbox or exposes host proc as a workaround: a
505
+ read-only host proc can still disclose other processes' command lines. Ask the
506
+ host provider for a compatible private-proc setup.