Files
beaver-agent/README.md
T

258 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
- **Admin UI:** `http://localhost:62990/` or `https://<DOMAIN>/`, login from `ADMIN_USER` / `ADMIN_PASS`
## 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`
- `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
## Leisure: the hourly tick (`beaver_agent/jobs/leisure.py`)
The master gets an inject with no task in it every hour: what time it is,
what kind of hour this is, and what is left of the day's plan. The active
window (12:00-03:59) ticks every hour; the quiet one (04:00-11:59, the hours
the owner has never once written in) ticks only in the four hours the plan
names - twenty ticks a day.
The day runs noon to noon and the first tick of it is the planning one: the
model lays the remaining hours out into *mine* (leisure only, the owner's
business stays out) and *mixed*, and writes them to `мета/бобер/досуг.md`:
```markdown
досуг: вкл
сутки: 2026-09-08
тихие часы: 04, 06, 08, 10
мои часы: 13, 15, 18, 21, 00, 02, 06
```
**The model writes that file, the job only reads it**, so they cannot
overwrite each other; how many leisure slots are spent follows from the plan
and the clock, so there is no state to keep. No plan for the day (a restart,
a fresh day, a broken file) is not an error: the job falls back to defaults
and says so in the inject. Fewer than seven leisure hours in the plan are
topped up to seven by the job - the guarantee is the mechanism's, not the
model's.
A tick lands as `wake` and starts a turn once the master is idle; a busy
master or a message less than five minutes old moves it five minutes on,
up to three times, so it never cuts into a live exchange. If a `schedule`
inject of the model's own is due later in the same hour, the tick goes in
as `normal` instead and rides into that turn with it - one turn, both
reasons, neither swallowed. The cron sits at `:07`, not `:00`, because
rotation is on the hour and a tick waiting in the queue counts as a busy
master.
Nothing of this reaches Telegram by itself: a turn started by an inject
publishes no reply, so the only voice in it is the `say` tool.
Switches: `досуг: выкл` as the first line of the file for every day, and
`LEISURE=off` in `.env` to drop the job entirely.
## 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.