A fleet of always-on Claude Code agents ("wolves") that you can DM on Telegram. Each wolf lives on its own Linux host, has a persistent Obsidian-compatible brain ("den"), and syncs bidirectionally with your Mac. The whole pack is provisioned with one command.
A wolf is a Debian server (DigitalOcean droplet by default) running:
- Claude Code in a detached
tmuxsession undersystemd, listening on a Telegram channel via the officialclaude-plugins-official/telegramplugin. - Tailscale for private networking between your Mac and every wolf.
- Syncthing wired into a hub-and-spoke topology with your Mac, so each wolf's
den/(its memory, identity, and working files) is always in sync with~/Code/wolfpack/dens/<wolf>/on your laptop, viewable/editable in Obsidian.
You DM a wolf on Telegram → it runs Claude with access to its den → it responds. Meanwhile the pack has a shared read-only library at ~/Code/wolfpack/shared/ that the Mac pushes to every wolf, so skills and pack knowledge propagate automatically.
┌─────────────────────────────────────────────────┐
│ 1uk4's Mac (Hub) │
│ │
│ ~/Code/wolfpack/ │
│ ├── shared/ (git-tracked, send-only ──┼──┐
│ └── dens/ (gitignored, bidi sync) ───┼──┤
│ └── scout/ │ │
│ │ │
│ Syncthing @ localhost:8384 │ │
└─────────────────────────────────────────────────┘ │
│ Tailscale
┌────────────────────────┘
│
▼
┌─────────────────────┐
│ scout (DO droplet) │
│ │
│ /home/wolf/workspace/
│ ├── den/ ◄─── bidi
│ └── shared/ ◄─── receive-only
│ │
│ systemd → tmux → claude
│ └── telegram plugin
└─────────────────────┘
Sync rules:
shared/— Mac sends, wolves receive. Mac is the source of truth; wolves can't modify it.dens/<wolf>/— Bidirectional between Mac and that specific wolf. Each wolf only has its own den.- Never wolf-to-wolf (yet).
Network:
- Public SSH (port 22) is used only for the first bootstrap. After Tailscale comes up, all future runs go over the tailnet.
ufwallows inbound on thetailscale0interface, so Syncthing, SSH, and web UIs are reachable over the tailnet without exposing anything to the public internet.
- Homebrew — https://brew.sh
- Ansible —
brew install ansible(orpipx install ansible) - An SSH keypair for the pack:
Then add
ssh-keygen -t ed25519 -f ~/.ssh/wolfpack -C "wolfpack" -N ""~/.ssh/wolfpack.pubto your DigitalOcean account's SSH keys (Settings → Security → Add SSH Key) before creating any droplet, so DO bakes it into/root/.ssh/authorized_keyson first boot. - A Tailscale account — https://tailscale.com — and a reusable auth key from https://login.tailscale.com/admin/settings/keys.
- A Telegram bot per wolf — DM @BotFather, send
/newbot, save the token. - Your Anthropic account logged into Claude Code locally — because provisioning copies credentials from
root's first login on the wolf to thewolfuser.
bootstrap.sh will install Syncthing for you via Homebrew on first run.
git clone <this-repo> ~/Code/wolfpack
cd ~/Code/wolfpack
cp .env.example .env
$EDITOR .env # fill in real values
./bootstrap.shThat's it. bootstrap.sh takes you through the rest: prereq checks, IP prompting, Ansible run, mid-run pauses for any manual OAuth, and a final checklist.
TAILSCALE_AUTHKEY=tskey-auth-...
TELEGRAM_BOT_TOKEN_SCOUT=1234567890:AAH...
MAC_SYNCTHING_DEVICE_ID=ABCDEFG-HIJKLMN-OPQRSTU-VWXYZAB-CDEFGHI-JKLMNOP-QRSTUVW-XYZABCD
TAILSCALE_AUTHKEY— reusable key from the Tailscale admin.TELEGRAM_BOT_TOKEN_<WOLFNAME>— one per wolf, matching thewolf_namein inventory.MAC_SYNCTHING_DEVICE_ID— the 56-char ID from your Mac's Syncthing (top-right menu → "Show ID").
all:
vars:
wolf_user: wolf
owner_telegram_id: "YOUR_TELEGRAM_USER_ID"
mac_syncthing_device_id: "{{ lookup('env', 'MAC_SYNCTHING_DEVICE_ID') }}"
children:
wolves:
hosts:
wolf-01:
ansible_host: 64.23.132.160 # DO droplet public IP (swapped to Tailscale after bootstrap)
ansible_ssh_private_key_file: ~/.ssh/wolfpack
ansible_ssh_extra_args: "-o IdentityAgent=none"
wolf_name: scout
telegram_bot_token: "{{ lookup('env', 'TELEGRAM_BOT_TOKEN_SCOUT') }}"Your numeric Telegram user ID (owner) goes in owner_telegram_id. You can get yours from @userinfobot.
Run from the repo root. All prereq failures print the exact fix command.
- Checks for
ansible-playbook→ install hint if missing. - Checks for
~/.ssh/wolfpackkeypair →ssh-keygencommand if missing. - Checks
.env→ creates from.env.exampleif missing; flags anyREPLACE_MEvalues with instructions on where to get each secret. - Checks Syncthing on your Mac → installs via
brew install syncthingif missing, starts the service if not running. - Creates
shared/anddens/inside the repo (dens is gitignored). - Lists wolves in inventory and prompts for the current IP of each one. Press Enter to keep the existing value or paste a new IP after a droplet rebuild.
- Clears stale SSH host keys for both old and new IPs.
- Runs the Ansible playbook with any extra args forwarded.
Roles run in this order against every wolf in the inventory:
tailscale— Installs Tailscale, authenticates with the auth key, openstailscale0in UFW.bun— Installs Bun (runtime for the Telegram plugin's MCP server).claude-code— Installs Node.js +@anthropic-ai/claude-code. Auto-copies Claude credentials fromroot's local login to thewolfuser. If neither is logged in, the playbook pauses with instructions for a one-timeclaude auth loginin a second terminal.telegram— Installs the official telegram plugin, writes the bot token, pre-allowlists your owner ID so stranger DMs are dropped.workspace— Creates the full den skeleton (see Den structure) and pre-trusts/home/wolf/workspace/denin~/.claude.jsonso Claude won't hit the "trust this folder?" prompt under systemd.syncthing— Installs Syncthing, calls its local REST API to add your Mac as a remote device and create two folders (den-<wolf>as send/receive,wolfpack-sharedas receive-only) both shared with your Mac.wolf-service— Deploys a systemd unit that runsclaude --channels plugin:telegram@claude-plugins-officialinside a detachedtmuxsession. A smoke test polls the tmux pane for the "Listening for channel messages" banner and fails the play loudly if it doesn't appear within ~120s.
Post-tasks (on localhost):
- Rewrites
inventory/hosts.ymlfor that host to point at its Tailscale IP. - Rewrites
~/.ssh/config'sHost wolfpackalias to the Tailscale IP. - Prints a final checklist with anything that still needs a human — mostly the one-time Mac-side Syncthing folder accepts.
Every wolf's /home/wolf/workspace/ looks like this:
workspace/
├── den/ # Private brain (bidi sync with Mac)
│ ├── CLAUDE.md # Identity + role + startup instructions
│ ├── SOUL.md # Personality, voice, boundaries
│ ├── MEMORY.md # Routing index → memory/*.md
│ ├── memory/
│ │ ├── human.md # What the wolf knows about you
│ │ ├── decisions.md # Key decisions
│ │ ├── lessons.md # Mistakes + learnings
│ │ └── daily/
│ │ └── YYYY-MM-DD.md
│ ├── tasks/
│ │ ├── inbox.md # New assignments from you
│ │ ├── active.md # Currently working on
│ │ └── done.md # Append-only log
│ ├── knowledge/ # Domain notes the wolf creates
│ ├── reports/ # Long-form output for you
│ └── .stignore # .obsidian/, .DS_Store, etc.
└── shared/ # Pack library (receive-only from Mac)
├── skills/
├── templates/
└── pack/
wolf-service runs claude with WorkingDirectory=/home/wolf/workspace/den, so Claude picks up den/CLAUDE.md as its main instruction file. Referencing ../shared/ in CLAUDE.md points it at the pack library.
~/Code/wolfpack/dens/scout/on your Mac ↔/home/wolf/workspace/den/on scout — bidirectional. Edit either side, changes propagate.~/Code/wolfpack/shared/on your Mac →/home/wolf/workspace/shared/on every wolf — one-way. Editingshared/in the repo and committing to git is how you push updates to the whole pack.
Open ~/Code/wolfpack/dens/<wolf>/ as an Obsidian vault, or create a master vault at ~/Code/wolfpack/dens/ that treats each wolf as a subfolder. You get graph view, search, and direct editing of every wolf's memory.
Assign a task by editing dens/scout/tasks/inbox.md in Obsidian. Syncthing pushes it to the wolf within seconds, and the wolf picks it up on its next session.
- Create a new DigitalOcean droplet (Debian 13 recommended) with
~/.ssh/wolfpack.pubattached. - Create a new bot via @BotFather, copy the token.
- Add the token to
.env:TELEGRAM_BOT_TOKEN_SENTINEL=... - Add the host to
inventory/hosts.yml:wolf-02: ansible_host: <public IP> ansible_ssh_private_key_file: ~/.ssh/wolfpack ansible_ssh_extra_args: "-o IdentityAgent=none" wolf_name: sentinel telegram_bot_token: "{{ lookup('env', 'TELEGRAM_BOT_TOKEN_SENTINEL') }}"
./bootstrap.sh— or to provision only the new wolf:./bootstrap.sh --limit wolf-02.- During the mid-run pause (if any), log into Claude once on the new droplet via
claude auth login. - After the run, accept the two Syncthing folder shares on your Mac:
den-sentinel→~/Code/wolfpack/dens/sentinel(Send & Receive)wolfpack-shared→~/Code/wolfpack/shared(Send Only, may already exist from scout)- Click Edit on the new device and set Addresses to
tcp://<tailscale-ip>:22000.
Only two things, and both are one-time per wolf:
- Claude OAuth login on the droplet — Claude Code's OAuth flow needs an interactive TTY; it can't be piped through Ansible. The playbook pauses with the exact
sshandclaude auth logincommands to paste into a second terminal. - Mac-side Syncthing folder accepts — We can configure everything from the wolf side via Syncthing's REST API, but the Mac has to actually click "Accept" on the incoming device + folder shares. The playbook prints the exact click-through steps.
Most likely cause: Claude inside the tmux session is stuck on a first-run dialog (folder trust, warnings, etc.). Attach to the live pane:
sudo -iu wolf tmux attach -t <wolf_name>
Read what's on screen. Press the right key to dismiss any prompt. Detach with Ctrl-b d (never Ctrl-c — that kills Claude).
The Mac can't reach the wolf's Syncthing daemon. Check:
- UFW on the wolf —
ufw status | grep tailscale0. Should show anALLOW INrule. Thetailscalerole opens this automatically; if it's missing, run the role again. - Device address on the Mac — Click Edit on the wolf device in Mac Syncthing and set Addresses to
tcp://<tailscale-ip>:22000instead ofdynamic. - The actual Syncthing process —
ss -tlnp | grep 22000on the wolf should showsyncthinglistening.
You probably logged in as root but Claude is running as wolf. Credentials aren't shared between Unix users. Either:
sudo -iu wolf claude auth loginto log in as the wolf user directly, or- Let the playbook's
claude-coderole auto-copyroot's credentials towolf(which it does whenrootis authed).
Rebuild gives the droplet a new host key. bootstrap.sh clears stale known_hosts entries for you, but if you hit this outside of a playbook run:
ssh-keygen -R <tailscale-ip>
ssh-keygen -R <public-ip>
wolfpack/
├── .env.example # Template for secrets
├── .gitignore # Ignores .env, dens/, syncthing metadata
├── README.md # You are here
├── ansible.cfg # Points at inventory/hosts.yml
├── bootstrap.sh # One-command entry point
├── inventory/
│ └── hosts.yml # Wolf inventory + group vars
├── playbooks/
│ └── bootstrap.yml # Main play: pre_tasks + roles + post_tasks
├── roles/
│ ├── tailscale/ # Install + auth + UFW tailscale0 rule
│ ├── bun/ # Install Bun as the wolf user
│ ├── claude-code/ # Install CLI + copy credentials
│ ├── telegram/ # Install plugin + configure access
│ ├── workspace/
│ │ ├── tasks/main.yml # Build the den skeleton
│ │ └── templates/den/ # CLAUDE.md, SOUL.md, MEMORY.md templates
│ ├── syncthing/ # Install + REST API folder config
│ └── wolf-service/
│ ├── tasks/main.yml # Deploy unit, smoke-test pane
│ ├── handlers/main.yml # restart wolf handler
│ └── templates/wolf.service.j2 # systemd unit
├── dens/ # gitignored; Mac-side wolf dens live here
└── shared/ # git-tracked; pushed to every wolf
├── skills/
├── templates/
└── pack/
- No secrets in git.
.envis gitignored; credentials live inkeys/(also gitignored). - No public internet exposure after Tailscale is up. UFW allows SSH on public interfaces only until you firewall it off;
tailscale0is the only interface Syncthing and other services listen on. - Telegram allowlist by default. The
telegramrole writes anaccess.jsonthat pre-allowlists only your owner ID (owner_telegram_idin inventory). Strangers DMing the bot get dropped silently. - Shared folder is receive-only on wolves. A compromised wolf can't poison the shared skill library.
- Claude runs as the unprivileged
wolfuser, not root, withsudoaccess only if you configure it.
Your own. Not affiliated with Anthropic.