Files
beaver-agent/README.md
T

249 lines
12 KiB
Markdown

# beaver-agent
My real [beaver-gateway](https://git.kotikot.com/beaver/beaver-gateway) setup: the Beavering Manager, a personal agent that lives on a home server, reads an Obsidian vault, talks through Telegram and deep chats in the vault, remembers people and promises, and has hands (Komodo, Home Assistant, T3 Code threads, calendars, Firefly, Telegram history through beavergram). The narrative and the docs are on [the site](https://beaver.kotikot.com/docs); this README is the operator's part: how to run it.
Uses Claude Code and Raycast subscriptions for agents, has some MCPs set up. You can use this as-is or modify to match your needs: `config.py` assembles the gateway from the `beaver_agent/` package. It is opinion, not core - every piece is meant to be rewritten for your own vault.
Seeding memory from an existing vault (old chats into digests, diary days and thoughts into notes about people) is one run of `scripts/seed_memory.py`; `--dry-run` first.
## Requirements
- Docker + Docker Compose
- Obsidian Sync (for `obsidian-headless`)
- claude.ai sub
- raycast sub
- Firefly III and its PAT
## First launch
### 1. Create `.env` and `raycast.json`
```bash
cp .env.example .env
# every empty value is documented inline; secrets: openssl rand -hex 32
nvim .env
# build raycast config on your mac (beta by default, consider checking raycast-api for instructions)
raycast-api init
# copy config.json to your deployment server
docker compose up -d
```
### 2. Set up Obsidian Sync
```bash
docker exec -it beaver-obsidian ob login
docker exec -it beaver-obsidian ob sync-setup --vault "yourvault"
docker restart beaver-obsidian
```
Check:
```bash
docker exec beaver-obsidian ls /vault
```
Only markdown is synced by default. To sync everything:
```bash
docker exec beaver-obsidian ob sync-config \
--file-types image,audio,video,pdf,unsupported
docker restart beaver-obsidian
```
### 3. Set up claude
On your mac run `claude setup-token` and put the result into `.env` as
`CLAUDE_CODE_OAUTH_TOKEN`. That is the whole auth: no `/login` inside the
container, no dialogs. The token draws from your subscription limits like
regular Claude Code; rotate it by running `claude setup-token` again.
Claude agents run on the Claude Agent SDK. Prompts are granules under
`мета/бобер/промпты/` in the vault - plain markdown, the `<tag>` wrappers
and the order live in `beaver_agent/prompts.py`; skills are the folders under
`мета/бобер/скиллы/` (each becomes a plugin, keep `name:` in SKILL.md
latin), which window loads which set is `beaver_agent/skills.py`. The vault
must be synced before the gateway can start. The model process runs as
`beaver-runner` with a whitelisted environment; the vault is mounted
read-write (it is the dispatcher's home), and `beaver_agent/policy.py` keeps
it out of `.obsidian`, `мета` outside `мета/бобер`, and from deleting
anything but its own files.
### 4. Mint a token
Open admin at `http://localhost:62990/` (or `https://<DOMAIN>/` if Caddy), sign in with `ADMIN_USER` / `ADMIN_PASS`, go to **Tokens → Create**, scope `*` for first run.
### 5. Smoke test
```bash
docker exec beaver-gateway ls /vault | head
docker logs beaver-gateway | grep -i "agent registered"
curl http://localhost:62990/anthropic/v1/models \
-H "Authorization: Bearer <YOUR_TOKEN>"
```
## Where to plug things in
The admin dashboard renders ready-to-copy URLs and snippets for each of these
Everything is on one port under its own path; Caddy forwards the whole domain as is.
- any Anthropic client: `http://localhost:62990/anthropic` or `https://<DOMAIN>/anthropic`, model = agent name (`beaver-opus-high` etc)
- MCP clients (Claude Desktop, Raycast extension): `/mcp/<name>/`, discovery page at `/mcp/`
- Obsidian companion plugin: `/md` as the plugin's "Base URL", `/api` for the panel
- voice points around the flat: `POST /voice/` with a key of scope `voice` (see below)
- **Admin UI:** `http://localhost:62990/` or `https://<DOMAIN>/`, login from `ADMIN_USER` / `ADMIN_PASS`
## Voice points (`beaver_agent/voice/`)
ESP32 boxes with microphones around the flat, built by a neighbour, post
their own JSON and get an answer back:
```sh
curl -X POST https://<DOMAIN>/voice/ -H "X-Api-Key: <VOICE_KEY>" -d '{
"room": "andreii", "device": "esp32-1",
"request_id": "3fd0d6ed-...", "content": "ало клод как дела"}'
{"request_id":"3fd0d6ed-...","room":"andreii","device":"esp32-1",
"reply":"живу, слушаю","conversation":"…"}
```
`"stream": true` in the body answers `text/event-stream` instead: `delta`
frames with pieces of the text, `done` with the same body as above.
- **One branch for the whole flat**, titled 🎤 Голосовой, so Telegram gives
it a topic under that name: what was asked and what was answered is
readable there, and writing in that topic talks to the same agent.
A cron at 04:05 starts the branch over in the same topic, the way the
master rotates; a day nobody spoke in is left alone.
- **Its own agent** (`beaver-voice`, haiku): `cwd=/tmp`, no tools, no MCP
hands, no gateway tools, and a prompt that ships in this repo instead of
the vault. A stranger may be standing at the speaker, so the agent has
nothing to leak rather than instructions not to - and `Recall`/`ReplyLog`
are muted for it, so the envelope never hands it pointers into the vault.
- **Its own keys**: scope `voice` (Tokens → Create), which opens nothing
else - not `/api`, not the panel, not the token page.
## Exposing to the internet
`caddy/site.caddy` holds the routes (one `reverse_proxy`, domain from `BEAVER_DOMAIN`); on a shared server it is bind-mounted into that server's Caddy as `projects.d/beaver-agent/`, on its own box `caddy/docker-compose.yml` runs a Caddy around it.
```bash
cd caddy
cp Caddyfile.example Caddyfile
cp .env.example .env # BEAVER_DOMAIN, BEAVER_UPSTREAM, Cloudflare token if any
docker compose up -d
```
Set `PUBLIC_BASE_URL=https://<DOMAIN>` in beaver-agent's `.env` so advertised endpoints use the outside origin.
## Deploy: `main` is work, `stable` is production
Komodo deploys only the `stable` ref; pushing `main` deploys nothing. Commit
during the day, then ship when the master thread is idle:
```bash
make deploy # git push origin main:stable
```
The gateway image is built from `beaver-gateway#${GATEWAY_REF}` at deploy
time, so push the gateway first. `docker-compose.yml` has a healthcheck on
`/healthz`; Komodo waits for it before it considers the deploy done.
To drain first (архитектура §8.6), call the deploy hook instead of pushing
by hand: it waits until no master turn is running, then asks Komodo:
```bash
curl -X POST https://<DOMAIN>/hooks/deploy -H "Authorization: Bearer <TOKEN>"
```
Needs `KOMODO_URL` / `KOMODO_KEY` / `KOMODO_SECRET` in `.env` (and
`KOMODO_STACK`, default `beaver-agent`). Komodo alerts can be pointed at
`POST /hooks/komodo` the same way - they land in the master as an urgent
inject. Jobs, the queue and the subscription window are on the admin **Jobs** page.
The same key backs the `komodo` tool the dispatcher and deep chats get
(`beaver_agent/hands/komodo.py`): one MCP tool with an enumerated
`action` - `status`, `stacks`, `containers`, `logs`, `search_logs`, `updates`,
`update`, `deploy`, `restart`, `exec`. `exec` is `docker exec` into any
container of the fleet except infrastructure ones (`exec_deny`, periphery by
default); prune, destroy and a host terminal do not exist in the enumeration,
so they cannot be asked for. The key never reaches the model process.
## Layout: `config.py` + `beaver_agent/`
`config.py` is a page: it imports the pieces and calls `Gateway(...)`. The
pieces live in `beaver_agent/`, one concern per module:
- `vault.py` - paths and the agent's zone, `TZ`
- `prompts.py` - how each role's system prompt is assembled from granules
- `skills.py` - which window loads which skill set
- `policy.py` - `PreToolUse` rules and vault zones (`ZONES`, `VAULT_POLICY`)
- `hands/` - every MCP the setup has and which agent gets which one; the
connectors (komodo, home assistant, vibegram) live here too
- `agents.py` - the roles (dispatcher, deep, distiller, curator, triage,
raycast) and the list of instances with models and effort
- `frontends.py` - the windows: Telegram for master and branches, vault
files for deep chats, `/api`, `/admin`, `/anthropic`, `/mcp`, `/voice`
- `voice/` - the voice points: the window, the branch and its nightly
rotation, the prompt the cheap agent runs on
- `texts.py` - everything the gateway says to the model or to the user,
in Russian (the gateway's own defaults are English)
- `memory/` - the recall block under a message, the reply log, handouts
and seeds, the vault watcher, the curator briefing
- `jobs/` - crons and webhooks, one file per job
## Policy (`beaver_agent/policy.py`)
`bypassPermissions` everywhere; the boundary is `PreToolUse` rules declared
per agent (`ClaudeAgent.policy`): the vault is the dispatcher's home, so it
may create, edit and move anywhere except `.obsidian` and `мета` outside
`мета/бобер`, and may delete only its own files; the distiller and curator
stay in `мета/бобер`; `rm`/`mv`/`cp`/`tee`/`sed -i`/redirects into the vault
outside those zones are refused with a reason; `mcp__firefly__store_*` and
`update_*` need the `firefly` skill opened first in the same session, and the
master cannot open the `vault/` skills at all (a branch can). Every tool call
lands in the admin **Audit** page as `tool_call`. Run `make check`.
## Editing the setup on the mac
`make sync` installs `../beaver-gateway` editable (extra `local`), so
imports resolve against the checkout you are working on; `uv sync --extra
prod` pulls the gateway from git instead. Neither is used by the image - the
container builds the gateway itself. To assemble the config against your
local vault: `BEAVER_VAULT=~/path/to/vault uv run python -c
'from beaver_gateway import config; from pathlib import Path;
config.load(Path("config.py"))'` with a `.env` next to it.
## Useful commands
```bash
# shell into the gateway container (claude CLI, bun, python with venv all live here)
docker exec -it beaver-gateway bash
# what obsidian-sync has pulled
docker exec beaver-obsidian ob status
# force a one-off sync (don't wait for the continuous tick)
docker exec beaver-obsidian ob sync
# per-service logs
docker compose logs -f gateway
docker compose logs -f obsidian-headless
# pull the latest beaver-gateway (when GATEWAY_REF=main)
docker compose build gateway && docker compose up -d gateway
# reload config.py / beaver_agent without a full rebuild
docker compose restart gateway
```
## Gotchas
- **claude in the container doesn't see the vault** - `VAULT` in `beaver_agent/vault.py` is `/vault` *inside* the container, not on the host. Don't change it; `BEAVER_VAULT` is for assembling the config on the mac only.
- **a claude turn fails immediately** - check `docker logs beaver-gateway` for the `claude[<agent>]:` stderr lines. Usually auth: redo step 3.
- **gateway restarts in a loop right after first `up`** - `beaver_agent/prompts.py` reads prompt granules from `/vault/мета/бобер/промпты`; until Obsidian Sync has pulled the vault they are missing. Finish step 2, it settles.
- **the model cannot write into `мета/бобер`** - the entrypoint grants `beaver-runner` an ACL on the two rw sub-mounts once at start; files that Sync creates later inherit it through the default ACL. If `setfacl` is unsupported on the volume it falls back to `chown`, and then files Sync writes afterwards as root stay read-only for the model until the next restart.