Merge p2/english: English entry point (P2)

This commit is contained in:
daniel wang 2026-11-09 17:56:16 +08:00
commit f2bf838e6d
7 changed files with 322 additions and 0 deletions

118
README.en.md Normal file
View File

@ -0,0 +1,118 @@
# AnyBot
[中文 README](README.md) · **English**
Your own AI bot. It chats, and it *does things*: files, shell commands, your browser, your screen, scheduled jobs. Self-hosted — your data stays on machines you own.
- Every bot can use a different model: xAI Grok, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama (local), or any OpenAI-compatible endpoint
- Desktop app (macOS, Linux) + mobile web app (install to home screen, push notifications)
- Multiple computers: one **brain**, the others are its **hands** — all linked through your own account server, no ports opened
## Demo
<!-- Drop a GIF/video at docs/demo/anybot.gif (see docs/demo/README.md) and it shows here. -->
📹 *No recording yet — see [docs/demo/README.md](docs/demo/README.md).*
## How it compares
| | AnyBot | OpenClaw | OpenHands | Khoj | UI-TARS-Desktop |
|---|---|---|---|---|---|
| Positioning | Personal bot across your machines | Agent gateway on your hardware | Coding agent in sandboxes | Personal knowledge/RAG assistant | GUI agent on one desktop |
| Chat with your own bots | ✅ multi-bot, per-bot model | ✅ via gateways | coding-focused | ✅ | — |
| Controls your real desktop/browser | ✅ + approvals, VNC takeover | via plugins | sandboxed, not your desktop | — | ✅ (its whole product) |
| Multi-device brain/hands/phone | ✅ built in, zero open ports | bring your own | — | server + apps | single machine |
| Bot's own sandboxed computer (Docker, optional gVisor) | ✅ | host shell | ✅ | — | — |
| Secrets never enter the model (vault fills forms directly) | ✅ | — | — | — | — |
| Phone web app + push, no app store | ✅ | community | — | ✅ | — |
| One binary, JSON files, no database server | ✅ | ✅ | — | needs services | Electron |
| License | Apache-2.0 | MIT | MIT | AGPL-3.0 | Apache-2.0 |
AnyBot's bet: a bot that touches your real machines is only trustworthy when the **harness** — not the model — owns the safety gates: per-action approvals, a sensitive-click policy the model cannot talk its way past, secrets painted out of screenshots, and an audit trail that survives crashes.
## Install
Pick one row, paste it into a terminal. The script installs what's needed (system packages, Rust, Docker).
| I want… | On which machine | Command |
|---|---|---|
| **Desktop app** | macOS, Linux with a screen | `curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh \| sh` |
| **Account server** (+ brain + HTTPS) | A Linux host with a domain | `curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh \| sh -s -- server` |
| **Add a computer as hands** | Any Linux (headless fine) | In AnyBot press “Add a computer”, paste the one-liner it shows |
First run compiles on your machine (5–15 min); later updates take 1–2 min. Read the script first: [install.sh](install.sh). It installs into your user folders — no root.
## Getting started
**One computer:** install the desktop app → Settings › Models → paste an API key → chat.
**Phone + several computers:** run the server once (asks only for URL, account, password; gets HTTPS by itself), open the URL on your phone and “Add to Home Screen”, then pair other computers with one-time pairing codes. Any Linux box can become the bot's own computer, running inside Docker (optionally gVisor). A Mac can be the brain instead of a server ([docs/multi-device.md](docs/multi-device.md), method B). No database to run: state is a handful of JSON files, crash-safe writes; back up two Docker volumes.
## Updates, removal
| What | Command |
|---|---|
| Update app / hands | re-run the install line |
| Update server | on the server: `cd ~/AnyBot && make server` → “keep settings, update, restart” |
| Uninstall the program | `cd ~/AnyBot && make uninstall` (your bots and chats are kept) |
| Stop a hands machine | `systemctl --user disable --now anybot` |
## Troubleshooting
```sh
cd ~/AnyBot && make doctor
```
It tells you what's missing. Common: `Killed`/`signal: 9` while compiling → add 2 GB swap; `requires rustc 1.95` → `rustup update stable`; Mac says “cannot verify developer” → right-click › Open once; phone says “brain offline” → the brain machine is asleep ([docs/multi-device.md](docs/multi-device.md)).
## Build from source
```sh
git clone https://code.30cm.net/daniel.w/AnyBot.git
cd AnyBot
make deps && make && make install # deps: system packages + Rust 1.95+ (one time)
```
`make run` · `make test` · `make doctor` · `make serve` (headless) · `make setup` (pair this machine) · `make server` · `make dist` · `make help`. Needs: Rust ≥1.95, a C compiler, git; on Linux also `pkg-config` + `libasound2-dev` (deps installs all).
## Deployment
| Method | For | Notes |
|---|---|---|
| **Docker Compose** (default) | server | `install.sh server`: relay + brain + Caddy (auto-HTTPS) containers; data in Docker volumes; Tailscale URLs skip Caddy ([compose file](deploy/server/docker-compose.yml)) |
| Unattended | servers, Ansible | `ANYBOT_SETUP_USER=… ANYBOT_SETUP_PASSWORD=… [ANYBOT_SETUP_DOMAIN=…] sh install.sh server` |
| Portainer/Coolify/Dokploy | existing PaaS | they take the standard `deploy/server/docker-compose.yml` — set `ANYBOT_USER`, `ANYBOT_PASSWORD`, `ANYBOT_RELAY_PUBLIC_URL` |
| **systemd** user service | hands, headless brains | `anybot setup` installs `anybot.service`; `loginctl enable-linger` to keep it running logged-out |
| **launchd** | Mac as brain/relay | see [docs/multi-device.md](docs/multi-device.md) |
| Gitea Actions | release builds on `v*` tags | see [docs/release.md](docs/release.md) |
## Security in one paragraph
Commands run in a macOS sandbox (deny ~/.aws, Keychain, AnyBot's own data; allow only the attachments dir); per-action approvals with a policy layer the model cannot negotiate; clicking anything labelled “send/pay/transfer” is asked *every time*, even in direct mode, with no grant that silences it; card numbers and OTPs in screenshots are painted black before the model sees them; everything read from outside is wrapped as `<untrusted>` data, never treated as orders; the bot's own computer is a Docker container (gVisor for a second wall); relay stores only hashes (argon2id) and supports 2FA with recovery codes; egress binds loopback + detected bridge gateways, never 0.0.0.0. Details: [docs/architecture.md](docs/architecture.md) (Chinese; [English summary](docs/en/architecture.md)).
## Documentation
| Doc | Contents |
|---|---|
| [docs/install.md](docs/install.md) ([en](docs/en/install.md)) | per-platform details, manual steps, FAQ |
| [docs/multi-device.md](docs/multi-device.md) ([en](docs/en/multi-device.md)) | brain / hands / screen: setup, pairing, offline |
| [docs/architecture.md](docs/architecture.md) ([en](docs/en/architecture.md)) | layers, data storage, security design |
| [docs/protocol.md](docs/protocol.md) ([en](docs/en/protocol.md)) | relay API and long-connection wire format |
| [docs/release.md](docs/release.md) | packaging, CI |
| [FEATURES.md](FEATURES.md) | every feature (Chinese) |
## License
Apache License 2.0 — see [LICENSE](LICENSE). Copyright the contributors (The AnyBot contributors). Calls to third-party services (xAI, OpenAI, …) go through their public APIs; follow each service's terms and your own key agreements.
## Repository layout
```
install.sh one-line installer (app, hands, server)
src/ AnyBot: desktop app, headless, `anybot setup`
relay/ account server
mobile/ phone web app (single file, embedded)
box/ the bot's own computer (Docker container)
deploy/ per-platform install & deployment files
docs/ technical docs (docs/en/ = English)
bench/ computer-use benchmark (tasks, scoring, red-team suite)
```

View File

@ -1,5 +1,7 @@
# AnyBot # AnyBot
中文 · [English →](README.en.md)
你自己的 AI Bot。可以聊天,也能幫你動手做事:處理檔案、執行指令、操作瀏覽器和螢幕、跑例行任務。 你自己的 AI Bot。可以聊天,也能幫你動手做事:處理檔案、執行指令、操作瀏覽器和螢幕、跑例行任務。
- 每個 Bot 可以用不同的 AI:xAI Grok、OpenAI、Claude、Gemini、OpenRouter、OpenCode Go、Ollama(本機),或任何 OpenAI 相容的服務 - 每個 Bot 可以用不同的 AI:xAI Grok、OpenAI、Claude、Gemini、OpenRouter、OpenCode Go、Ollama(本機),或任何 OpenAI 相容的服務

16
docs/demo/README.md Normal file
View File

@ -0,0 +1,16 @@
# Demo media
The README shows a demo here. To record one:
1. Start AnyBot (desktop) and a bot with a key; run at 1440p, hide notifications.
2. Record a 20–40 s clip of one complete task, e.g. "organise the Downloads folder"
(chat → plan → tool steps → files moved) and one computer-use task
(bot opens the browser, fills a form, stops before Send — the approval card
popping up **is** the product).
3. Export GIF: `ffmpeg -i clip.mov -vf "fps=12,scale=960:-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse" docs/demo/anybot.gif`
(keep it under ~8 MB; if bigger, cut to one task).
4. Name it `docs/demo/anybot.gif`. The README comment shows where to un-comment
the `<img>` line.
Preferred: one GIF (desktop, computer-use with the approval card) + one 30 s
MP4 (`demo.mp4`) of the phone app pairing a new machine.

48
docs/en/architecture.md Normal file
View File

@ -0,0 +1,48 @@
# Architecture (English summary)
The canonical, up-to-date document is [docs/architecture.md](../architecture.md) (Chinese). This is a faithful, condensed English version for contributors who don't read Chinese.
AnyBot is one Rust binary (`anybot`, egui/eframe UI) with three modes, plus a small account server (`anybot-relay`):
| Mode | Command | Purpose |
|---|---|---|
| Window | `anybot` | desktop app |
| Headless | `anybot --serve` | servers, always-on machines, "hands" |
| Setup | `anybot setup …` | terminal pairing wizard (`src/setup.rs`) |
## Layers
```
UI (src/app.rs, src/app/*) — chat, sidebar, settings, computers, file preview, brain mirror
│ │
Turn loop (agent.rs) Remote (remote.rs, tunnel.rs, wire.rs)
call model, run tools, phone API, device pairing,
approvals, summarising brain sync, long WebSocket to relay
│ │
Models Tools (tools.rs, cloudbox.rs, botbrowser.rs, computer.rs,
(providers) axread.rs/axwrite.rs, macapps.rs, vault*.rs, mcp.rs, memory.rs,
routines.rs, triggers.rs, channels.rs)
```
Key modules: `agent.rs` (one turn: system prompt, streaming, tool loop, approval gates, per-step model routing `StepPlan`), `providers.rs` (OpenAI-compatible/Anthropic/OpenCode… + streaming + retries + a vision helper for models that can't see images), `policy.rs` (allow/ask/deny per action), `computer.rs` (one `computer` tool: screenshot, click/type, plus semantic actions `menu_click`/`fill_form`/`wait_for`; macOS reads the real accessibility tree, screenshots are annotated with numbered marks), `cloudbox.rs`+`box/` (the bot's own Docker computer with desktop + browser), `vnc.rs` (view/take over the bot's computer through the relay), `httpd.rs` (tiny shared HTTP server), `mobile/index.html` (phone UI, single embedded file).
## Storage
All in the data dir (macOS `~/Library/Application Support/AnyBot/`, Linux `~/.local/share/AnyBot/`, `%APPDATA%\AnyBot\`; `ANYBOT_DATA_DIR` overrides): `state.json` (bots, chats, settings, keys — written via temp file + rename, never half-written), `attachments/`, `browser/` (bot browser profile), `oplog/bot-<id>.jsonl` (append-only work log: every step written to disk *before* it runs; used to repair chats and offer a "continue" card after a crash), `image-notes.json`, `anybot.lock` (OS file lock: one AnyBot per data dir).
## Security design
- **Approval gates** (`policy.rs`): actions run directly, ask first, or are denied. "Direct mode" skips asks — except for the gates below, which have no grant.
- **Sensitive clicks** (labels containing send/pay/transfer/delete/publish…): asked *every time*, direct mode included — a harness policy the model cannot negotiate past.
- **Command sandbox (macOS)**: bot commands run under `sandbox-exec`; AnyBot's data dir, `~/.gnupg`, Keychain, `~/.aws` are unreadable/unwritable; only `attachments/` and `.ssh` are readable, nothing outside is writable. Model API keys are stripped from the environment. Documented exceptions: App Store app CLIs, `ps`/`top`, and a first-time-asked, pattern-filtered single `osascript -e`.
- **The bot's own computer**: a Docker container; with gVisor installed on the Docker host, isolation becomes two walls (virtual kernel + Chromium sandbox). On macOS a Lima VM running Docker+gVisor is provided (`deploy/mac/gvisor-vm.sh`).
- **Vault**: passwords, card numbers, CVCs never enter the model; AnyBot fills them into web forms directly.
- **Screenshots are painted before the model sees them**: card numbers and OTP boxes are masked black (`marks::mask_secrets`).
- **Everything read from outside** (screens, web pages, files) arrives wrapped in `<untrusted source="…">`; the system prompt makes it data, never orders. The real wall is the harness gates, not the wording.
- **Relay**: stores only hashes (argon2id; legacy PBKDF2 still works, upgraded on next password change); optional 2FA (TOTP + one-time recovery codes); one-page logout for all devices; login lockout per IP+account (8/5 min).
- **Egress**: `--serve` binds loopback + detected Docker bridge gateways only, never 0.0.0.0 (`src/egress.rs`).
- **docker.sock** is not mounted into the bot's container: a whitelisting docker-socket-proxy sidecar is used instead; rootless Docker works via `DOCKER_SOCKET`.
## The computer-use control chain
Model gets a screenshot + numbered marks built from the accessibility tree (macOS) → clicks a number, or names it (`press_named`) → harness translates to an AX press (preferred) or a pixel click (fallback) → the diff of the control tree between looks (`marks::diff`) is appended so the model sees only what changed. `bench/computer-use/` measures tasks end-to-end (model inference is ~90% of wall time) and includes the R1–R7 red-team suite for the gates above.

27
docs/en/install.md Normal file
View File

@ -0,0 +1,27 @@
# Install details (English summary)
Canonical document: [docs/install.md](../install.md) (Chinese).
`install.sh` does four steps: system packages (per-distro names: apt/dnf/pacman/zypper), toolchain (rustup → `~/.cargo`; Docker for the server mode), sources into `~/AnyBot`, then build + install. No root for the app; it picks desktop vs. hands automatically (Mac / Linux-with-desktop → app, headless Linux → hands). Low-RAM machines compile with fewer parallel jobs (one per 2 GB). Env: `ANYBOT_DIR`, `ANYBOT_BRANCH`.
## Requirements
| Platform | Needs |
|---|---|
| All | Rust **≥ 1.95** via rustup (apt's is too old), git, make, C compiler |
| macOS ≥ 12 | Xcode Command Line Tools (`xcode-select --install`) |
| Linux | `build-essential pkg-config libasound2-dev` (+ X11/Wayland, notify, clipboard libs — install.sh installs them) |
| Windows | VS Build Tools (C++); `cargo build --release` works, installer packaging is not done |
## Make targets
`make deps` (system packages + Rust, one time) · `make` (build) · `make install` (Mac → `~/Applications/AnyBot.app`, Linux → `~/.local/bin/anybot` + menu entry) · `make run` · `make test` · `make doctor` (what's missing on this machine) · `make serve` (headless) · `make setup` (pair this machine to your brain) · `make server` (host the account server here) · `make dist` (release archives into `dist/`) · `make relay` (build the relay alone).
## FAQ
- **`Killed` / `signal: 9` while compiling** — out of memory; add swap: `sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile`.
- **`requires rustc 1.95`** — `rustup update stable`; don't use the distro's rustc.
- **`alsa` / `pkg-config` not found** — `make deps`.
- **Mac: "cannot verify developer"** — right-click the app › Open, once.
- **Server URL unreachable** — DNS A record? ports 80/443 open? `cd ~/AnyBot/deploy/server && docker compose logs caddy`.
- **Uninstall** — `make uninstall` removes the program, keeps your bots and chats.

44
docs/en/multi-device.md Normal file
View File

@ -0,0 +1,44 @@
# Multi-device: brain, hands, screen (English summary)
Canonical document: [docs/multi-device.md](../multi-device.md) (Chinese).
One AnyBot in your account is the **brain**: it stores every bot, chat, memory and schedule, and does the thinking. Other machines are its **hands** (screen, input, files, browser), phones are web clients. Everything talks through your **account server** (relay); each machine opens an outbound long connection, so nothing needs open ports or a static IP.
| Role | What it is | How to set |
|---|---|---|
| Brain | stores and thinks; keep it always on | logging into the account makes it the brain (default) |
| Screen | a machine with a window: shows and edits the brain's bots, and acts as hands | Settings › General › Phone & account › Brain › “use this as brain” |
| Hands | lets the brain's bots drive this machine | the “Add a computer” one-liner, or `anybot setup` |
| Phone | web app on the relay's URL | sign into the same account |
## Method A — server as relay + brain (recommended)
A Linux host (≥2 GB RAM) plus one of: a **domain** (A record, 80/443; HTTPS certificates issued automatically), a **Tailscale** HTTPS URL (`https://host.tailnet.ts.net`, no ports opened at all), or a **LAN IP** (self-issued, auto-renewed certificate — trust it once on each device; instructions in the Chinese doc).
```sh
curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh -s -- server
```
It asks three questions (URL, account, password), installs Docker if needed, gets HTTPS, creates the account, starts the brain. Update later with `cd ~/AnyBot && make server` (“keep settings, update, restart”). Release archives (built by Gitea Actions on `v*` tags) avoid building on the server: download `AnyBot-<ver>-server-<arch>.tar.gz`, unpack, `cd deploy/server && ./setup.sh`.
## Method B — a Mac as the brain (home, Tailscale)
1. `make relay`; install `anybot-relay` under `~/Library/Application Support/AnyBot/relay/` and load `deploy/mac/com.anybot.relay.plist` via `launchctl` (edit the URL inside first).
2. `tailscale serve --bg 8780` → you get `https://mac-mini.tailnet.ts.net`.
3. AnyBot › Settings › General › Phone & account → enter that URL, create the account, sign in. This Mac is now the brain; other devices pair to it like Method A.
## Pairing computers (hands)
“⋯ › Add a computer” on the phone (or Settings on the brain) shows a one-time pairing line like:
```sh
curl -fsSL https://your-domain/…/install.sh | sh -s -- hands https://your-domain 482913
```
The 6-digit code lives 10 minutes, works once, carries no password. The machine installs itself, registers a host token, starts `anybot.service`, and runs at boot (enable `loginctl enable-linger $USER` to keep it running when logged out).
## When a device can't connect
- “Brain offline” → the brain machine is off/asleep, AnyBot isn't running there, or it lost the relay connection (each side keeps an outbound connection; a network change recovers by itself in seconds).
- A hands machine offline → check `systemctl --user status anybot`; re-run the pairing line to re-pair.
- A screen machine keeps a read-only **brain copy** (`brain-copy.json`) so recent chats stay readable while the brain is away.

67
docs/en/protocol.md Normal file
View File

@ -0,0 +1,67 @@
# Protocol (English summary)
Canonical document: [docs/protocol.md](../protocol.md) (Chinese). For anyone modifying the relay or writing another client. All replies are JSON; errors are `{"error": "plain sentence"}` with an HTTP status.
## Identity
| Who | Proves itself with | From |
|---|---|---|
| Person (phone) | `Authorization: Bearer <session>` | `POST /relay/login` |
| An AnyBot (host) | `Authorization: Bearer <host token>` | `POST /relay/hosts` (after login) or `POST /relay/pair/claim` (pairing code) |
The server stores only hashes. Passwords are argon2id (legacy PBKDF2 hashes still sign in; upgraded on next password change).
## Relay endpoints
### Account
| Method | Path | Notes |
|---|---|---|
| POST | `/relay/signup` | `{user, password, invite}`. First account needs no invite; later ones need `ANYBOT_RELAY_INVITE` |
| POST | `/relay/login` | `{user, password, device}` → `{token}`; with 2FA on → `{need_2fa, challenge}` (valid 5 min). Lockout 8 tries / 5 min per IP+account |
| POST | `/relay/2fa/verify` | Second step. Sign-in: `{challenge, code}` → `{token}` (TOTP code, or a one-time recovery code → adds `used_recovery`). Enrollment (with session): `{code}` activates the new secret |
| POST | `/relay/2fa/enroll` | (session) → `{secret, uri, recovery_codes}` — codes shown once; 2FA is on only after a verified code |
| POST | `/relay/2fa/disable` | (session) `{code}` turns 2FA off |
| POST | `/relay/sessions/logout` | (session) kicks **every** device of the account, including this one |
| POST | `/relay/logout` | ends this session |
| GET | `/relay/me` | account, `two_factor`, usable AnyBots (`online`, `uses`, `hands`), signed-in devices, backup presence |
### Hosts and proxying
| Method | Path | Notes |
|---|---|---|
| POST | `/relay/hosts` | (person) `{name}` → `{id, token}`; re-registering a name rotates the token |
| POST | `/relay/hosts/<id>/remove` | (person) remove a host |
| GET | `/relay/host/ws` | (host) the long connection — see wire format below |
| GET | `/relay/host/next` · POST `/relay/host/reply/<id>` | legacy long-polling for hosts without WebSocket |
| GET | `/relay/host/peers` · GET/POST `/relay/host/members` | other hosts; team roles (admin/member) |
| POST | `/relay/host/notify` | push notification to the account's phones |
| POST | `/relay/host/sites/<name>` | publish a bot-made website at `/s/<name>/` |
| PUT/GET | `/relay/backup` | encrypted backup; the server cannot open it |
Proxying: `/h/<host>/api/...` (phone → that AnyBot, role owner/admin/member); `/relay/hosts/<host>/api/sync[/...]` (host → brain's `/api/sync…`, role `mirror`); `/relay/hosts/<host>/api/device/run` (host → hands, role `device`). The relay injects `user`, `role`, `from`. Offline → 503 immediately; no answer in 35 s → 504. VNC streaming: `POST /relay/screen-ticket {host}` → 10-minute ticket; `WebSocket /relay/vnc/<host>?t=|?ticket=` carries binary VNC bytes and typed text.
## AnyBot's own API (`remote::handle`)
Reachable only through the relay (or on the LAN with the phone password). Highlights: `GET /api/state?since=` (bots, activity, pending approvals; long-poll ≤20 s), `GET /api/bots/<id>`, `POST /api/bots/<id>/<action>` (`send`, `stop`, `answer`, `read`, `new`, `upload`…), `GET/POST /api/screen/mac` (drive this Mac from the phone, if enabled), `/screen` + `/api/vnc` (view the bot's computer, noVNC), and the brain-sync family: `GET /api/sync?since=`, `POST /api/sync/edit` (`{ops, client, seq}`), `/api/sync/act`, `/api/sync/upload/<bot>`, `GET /api/sync/file`, `POST /api/sync/models`, `POST/GET /api/sync/screen` (thumbnails; `{kind:"hold"|"wake"}`).
## Wire format (`src/wire.rs`)
WebSocket binary frames, each message: `[4-byte big-endian header length][header JSON][raw body]` — bodies are not base64'd; `z: true` means the body is deflate-compressed, `z_ok: true` says the asker understands compressed replies.
| `t` | Direction | Header fields | Meaning |
|---|---|---|---|
| `hello` | AnyBot → relay | `brain`, `hands` | which brain this machine follows; is it hands |
| `job` | relay → AnyBot | `id, method, path, query, user, role, from, z, z_ok` | a request for this machine |
| `reply` | AnyBot → relay | `id, status, ctype, headers, z` | answer to a job |
| `call` | AnyBot → relay | `id, to, method, path, query, z` | ask another host (only `/api/sync…`, `/api/device/run`) |
| `ret` | relay → AnyBot | `id, status, ctype, z, headers` | answer to a call |
| `vopen` / `vdata` / `vtext` / `vclose` | both | `sid`, `why` | VNC session lifecycle: open, bytes, typed text, close |
Both loops poll every 2 ms while busy, 40 ms idle (`wire::Pace`), then flush queued messages; keepalive via WebSocket ping/pong. On disconnect the relay answers pending jobs 503, the AnyBot fails pending calls.
## Sync ops (`/api/sync/edit`)
`set` (one key; `next_*` counters only grow), `patch` (map/list item-level), `merge` (providers/skills/mcp_servers on move), `bot_add` (id collision → new id), `bot_del` (primary bot protected), `bot_set`, `bot_patch` (routines keep the brain's run times), `bot_read`, `bot_order`.
`patch` shapes: `{"value": …}` replaces; `{"map": {"put": {…}, "del": […]}}`; `{"list": {"put": […], "del": [keys], "order": [keys]}}` where keys are item `id`s (or `name`, raw values as-is). Items without id/name, or duplicate keys → send the whole value instead.