beaver-agent
My real 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; 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
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
docker exec -it beaver-obsidian ob login
docker exec -it beaver-obsidian ob sync-setup --vault "yourvault"
docker restart beaver-obsidian
Check:
docker exec beaver-obsidian ls /vault
Only markdown is synced by default. To sync everything:
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
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/anthropicorhttps://<DOMAIN>/anthropic, model = agent name (beaver-opus-highetc) - MCP clients (Claude Desktop, Raycast extension):
/mcp/<name>/, discovery page at/mcp/ - Obsidian companion plugin:
/mdas the plugin's "Base URL",/apifor the panel - Admin UI:
http://localhost:62990/orhttps://<DOMAIN>/, login fromADMIN_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.
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:
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:
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,TZprompts.py- how each role's system prompt is assembled from granulesskills.py- which window loads which skill setpolicy.py-PreToolUserules 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 tooagents.py- the roles (dispatcher, deep, distiller, curator, triage, raycast) and the list of instances with models and effortfrontends.py- the windows: Telegram for master and branches, vault files for deep chats,/api,/admin,/anthropic,/mcptexts.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 briefingjobs/- 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:
досуг: вкл
сутки: 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.
The watchdog ring (beaver_agent/jobs/heartbeat.py)
An agreement with a foreign agent on someone else's home server: both print one line a day into a vibegram room of their own, and both watch the other's silence. The two rates are deliberately different: the beat is daily because that is the period we promise the neighbour, while the check runs every six hours, because a watchdog stepping once a day against a 36 h threshold would only notice silence some 60 h in - it would be measuring the wrong thing. If the neighbour goes quiet past the threshold, their human is the one who needs to hear about it, so the alarm goes to the master thread as a spawned triage job and the dispatcher decides whether to wake the owner.
The room is a second, independent Vibegram client (VIBEGRAM_HB), not
the one the dispatcher's vibegram tool and the triage job share. /api/pending
is a cursor on the hub's side: one client for both rooms would mean the triage
job eats the heartbeats, and the heartbeats drown in conversation. The model
gets no tool for this room - the watchdog is a mechanism.
The point of a watchdog is that it does not lie: an instrument that quietly fails to watch is worse than no instrument, because it looks healthy. So there are no defaults anywhere in it.
отметка(17:12 local) printshb <nick> at=… boot=… every=… src=…. The period is not a constant: it is derived from that job's own registered cron (run.job.cron). A schedule that does not reduce to "every N hours" getsevery=unknown src=none- lying with a number is worse than refusing.сторож(:42every six hours) asks for an explicit window (pending(limit=25)), finds the newesthb claude-kai …, and compares its age againstevery * 1.5whereeverycomes from the neighbour's own line. The clock it trusts isseen- when we saw the line - not theat=inside it, which is the neighbour's statement about himself.- Every run prints
ack claude-kai last=… seen=… res=… win=… armed=… thr=…into the room. Anything the run could not measure says so out loud there:every=unknownin his line falls back to the last known period and marks the thresholdthr=36h-fallback; a period never read once isthr=unknownand no alarm is taken. - Never having seen a beat is not an alarm, it is
armed=no- there is nothing to watch yet. Seven days ofarmed=noin a row tells the human once, so an unfinished ring does not quietly become permanent. - If the window's highest event id went backwards since the last run, the
window is looking at the past:
win=stale, and an alarm in that run is forbidden. Same for a hub that did not answer at all. - The alarm names all three possible causes - the neighbour's machine, his
cron, or the channel (the room itself) - and if our own write into the room
went through in that same run while neither
hbnorackcame back from him, it says to suspect the channel before the neighbour's death. It repeats at most once a day, not once a run.
State between runs is мета/бобер/.состояние/сторож.json; a lost file means
the ring disarms itself and starts the seven-day count again.
Setup is two variables in .env and one manual step:
# join the watchdog room with the invite code the neighbour sends
python3 scripts/vibegram_join.py <join-code> --nick <nick>
# put the token into .env as VIBEGRAM_HB_TOKEN, then
docker compose restart gateway
VIBEGRAM_HB_NICK is optional - without it the nick is VIBEGRAM_NICK. With
no VIBEGRAM_HB_TOKEN both jobs do nothing and the log says once at startup
that the watchdog is not configured. The neighbour's nick is the PEER
constant in beaver_agent/hands/heartbeat.py.
The job-run watchdog (beaver_agent/jobs/runs.py)
The scheduler has always written one row per run into job_runs - name,
status, the head of the traceback in error. Nobody ever read it. The channel
to the neighbour agent was dead for 38 hours straight, the scheduler recorded
that 231 times, and nothing reached a human: the outage surfaced ten days later,
by hand, out of curiosity. The record existed, the reader did not. This job is
the reader.
прогоны(:27every hour) readsjob_runsfor the last 7 days through the same connection the scheduler itself writes with (run.conversations.db.session()and theJobRunRecordmodel) - no second truth about where the journal lives.- An alarm is taken only off the tail: three or more
failedin a row counting back from a job's latest run, i.e. the job is lying down rather than having blinked once. A single failure is a network hiccup, and waking a human for it teaches them not to look. - The alarm says the job's name, how many runs in a row died, when the series
started, and the first line of the error - never the traceback. Replayed
over the real outage it reads: «джоб
вайбграмлежит 23 часа и дольше, 144 прогона подряд с 12.09 22:00 UTC: HubError: vibegram 530 bad_json». It repeats at most once a day per job, and when the job recovers one shortотбойline goes out and the counter resets. The window is a week, not a day, so a daily job (отметка,закрытие,голосовой) gets its three failures in a row in three days. - A job that stopped running is an alarm too, with its own text: «джоб
закрытиене запускался 2 суток при периоде 24 часа». For every job with a cron, the period comes from the registered cron (run.scheduler.jobs) via the sameevery_from_cronthe neighbour watchdog uses, and the last run is taken from the whole journal, not the window. The threshold is two periods: one missed fire happens honestly (a redeploy landed on the cron minute, and the scheduler does not catch up), two in a row do not. It is never finer than two of the watchdog's own periods (2 hours), because an hourly watcher cannot tell a shorter gap from its own blindness between runs. Time when the scheduler was not running non-critical jobs at all is not counted as a miss: before the process started, and while the watchdog's own hourly chain is broken (the gateway was down, or the budget was throttled and non-critical crons were deferred without a record). Same discipline as failures: once a day per job, and anотбойwhen a run shows up. - What silence does not cover, on purpose: jobs without a cron (webhooks,
events) are not measured at all; a job that has never appeared in the
journal waits silently for its first row; and a cron that does not reduce to
a regular period (
куратор,0 2,8,12,16,20 * * *) is skipped and named in the log as unguarded every run, because any number put in its place would be made up.кураторstill falls under the failure count. - An empty answer is a statement about the query, not about health. Zero rows in the last 24 hours (other than this job's own) means no job ran at all, and that is its own alarm with its own text - the scheduler is what to look at, not any one job. A failed query is no reading whatsoever: the run logs it and stays silent, because a database that did not answer is not evidence of health or of illness.
- Everything that goes out is masked (
mask): the gateway's sharedredactplus two local rules for what it cannot catch by construction - aNAME=valuepair in the middle of a line (a traceback puts them there) and the query string of any URL, which is dropped whole because the parameter carrying the key can be named anything.
State between runs is мета/бобер/.состояние/прогоны.json (who was told and
when, separately for failures and for silence). A lost file means a lying job is reported again and an отбой for
something reported before the loss never comes - the evidence for it is gone.
One blind spot, deliberate: the watchdog cannot report its own death. Its
own runs are excluded from both the failure count and the silence check
(run.job.name), because a job that is down will not file a report about
itself - an observer inside the object does not report the object's death. If
прогоны itself starts failing or stops running, nothing here will say so;
that needs an outside eye, like the watchdog ring above.
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
# 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 -
VAULTinbeaver_agent/vault.pyis/vaultinside the container, not on the host. Don't change it;BEAVER_VAULTis for assembling the config on the mac only. - a claude turn fails immediately - check
docker logs beaver-gatewayfor theclaude[<agent>]:stderr lines. Usually auth: redo step 3. - gateway restarts in a loop right after first
up-beaver_agent/prompts.pyreads 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 grantsbeaver-runneran ACL on the two rw sub-mounts once at start; files that Sync creates later inherit it through the default ACL. Ifsetfaclis unsupported on the volume it falls back tochown, and then files Sync writes afterwards as root stay read-only for the model until the next restart.