# disclaw My very own claw. Super experimental and only really meant for me for now. A personal Claude-style agent that lives on my Mac mini, talks back through Discord, and remembers things between conversations. Built on the [pi](https://pi.dev) agent harness. ## Architecture ``` Mac mini └── docker compose └── disclaw (one Node process) ├── Pi SDK ........... agent runtime + tools (bash, edit, read, write) ├── Discord client ... DM / mention triggered ├── Lane manager ..... one session per channel; persisted in .pi/; steers mid-turn └── Scheduler ........ croner, fires fresh sessions on cron + volumes: memory/ persistent markdown notes + scheduled jobs .pi/ persistent Pi session files + lane index skills/ read-only mount of skill instructions ``` ## Prerequisites - Node 20+ (for local dev) — Docker handles this in deployment - Docker + Docker Compose (for deployment) - An Anthropic API key (or another Pi-supported provider) - A Discord application + bot token ## Quick start — local CLI For testing memory and schedules before setting up Discord. ```bash cp .env.example .env # fill in ANTHROPIC_API_KEY npm install npm run dev:cli ``` You should see `skills: ..., memory, schedule` in the boot output. Try: - `remember that I prefer Go over Python` — writes `memory/.md`, updates `INDEX.md` - `what do you remember about me?` — re-reads INDEX.md and any relevant memory files - `list_schedules` — exercises the schedule tool `/exit` to quit. ## Discord bot setup 1. https://discord.com/developers/applications → **New Application**. 2. **Bot** tab → Reset Token → copy. Paste into `DISCORD_BOT_TOKEN` in `.env`. 3. **Bot** tab → Privileged Gateway Intents → enable **MESSAGE CONTENT INTENT**. 4. **OAuth2 → URL Generator** → scope `bot`, permissions `View Channel` + `Send Messages` + `Read Message History` + `Add Reactions`. Open the generated URL and add the bot to a server you control. The bot must share a server with you for DMs to work — there is no DM-only path. 5. In Discord: User Settings → Advanced → enable **Developer Mode**. Right-click your own name → **Copy User ID**. Paste into `DISCORD_ALLOWED_USER_IDS` in `.env` (comma-separated for multiple). Run it: ```bash npm run dev ``` The bot responds to DMs from allowed users and to @-mentions in any server channel it's been added to. All other messages are silently ignored. Each Discord channel/thread keeps its own Pi session history, and that history survives bot restarts via `.pi/sessions/`. ## Mac mini deployment 1. Stop the mini from sleeping (cron won't fire if it does): ```bash sudo pmset -a sleep 0 sudo pmset -a displaysleep 10 ``` 2. Set `TZ` in `.env` so cron expressions match wall clock: ``` TZ=America/Los_Angeles ``` 3. Build and start: ```bash docker compose up --build -d docker compose logs -f ``` 4. Confirm `disclaw bot ready as ...` and `scheduler: N job(s) registered` in the logs. Update after pulling new code: ```bash docker compose up --build -d ``` Stop: ```bash docker compose down ``` ## Memory The agent reads/writes plain markdown files in `memory/`. Format is documented in `skills/memory/SKILL.md`. Short version: - `memory/INDEX.md` is always loaded; one-line pointers to every memory file. - Each topic is its own `memory/.md` with `name` / `description` / `type` frontmatter. - Types: `user`, `feedback`, `project`, `reference`. Edit memory files directly in your editor whenever you want — the agent picks up changes the next time it reads them. ## Schedules Tell the agent things like "every Tuesday at 9am, summarize my Twitter feed" — it writes `memory/schedules/.md` with a cron expression and prompt. The scheduler watches that directory and fires each job in a fresh agent session at its cron time, posting the result back to the Discord channel where you scheduled it. Edit a schedule file directly to tweak the prompt or cron — the scheduler reloads on filesystem change. Cron is standard 5-field (`min hour dom mon dow`): | Expression | Means | |------------------|------------------------| | `0 9 * * 2` | Tuesday 9am | | `0 9 * * *` | Daily 9am | | `0 9 * * 1-5` | Weekdays 9am | | `*/30 * * * *` | Every 30 minutes | ## Customization - **Model:** set `DISCLAW_MODEL` (and optionally `DISCLAW_PROVIDER`) in `.env`. Default is `anthropic/claude-sonnet-4-6`. - **New skill:** drop a directory under `skills//SKILL.md` with frontmatter (`name`, `description`). Bind-mount means no rebuild — restart the container to pick up. - **New tool:** add a `defineTool` in `src/tools/`, register it in `src/agent.ts` → `createSession`. ## File layout ``` src/ ├── agent.ts shared deps + session factory ├── bot.ts Discord client + allowlist + dispatch ├── cli.ts terminal mode for local testing ├── lanes.ts per-channel session map; steers if streaming ├── scheduler.ts croner-based scheduler over memory/schedules/ ├── tools/schedule.ts schedule_task / list_schedules / cancel_schedule └── util.ts Discord chunking + schedule file IO skills/ ├── memory/SKILL.md └── schedule/SKILL.md memory/ persistent (Docker bind-mount) ├── INDEX.md └── schedules/ one .md per cron job .pi/ persistent (Docker bind-mount) ├── lane-sessions.json channel/thread/cli lane -> Pi session file └── sessions/ Pi JSONL session files for each lane ``` ## Troubleshooting - **Bot doesn't respond.** Check `DISCORD_ALLOWED_USER_IDS` matches your user ID exactly. Confirm MESSAGE CONTENT INTENT is enabled in the dev portal. In a server, the bot only responds to @-mentions, not all messages. - **`Model not found`.** `DISCLAW_MODEL` isn't in Pi's known list. Try the default `claude-sonnet-4-6`, or check `node_modules/@earendil-works/pi-ai/dist/models.generated.js`. - **Scheduler not firing.** Verify `docker compose logs disclaw` shows `N job(s) registered`. Confirm `TZ` is correct and `sudo pmset -g | grep sleep` shows `sleep 0`. - **Schedule fires but posts nothing visible.** The destination channel ID is probably wrong or the bot lost access to the channel. Open `memory/schedules/.md` and fix the `destination` field. - **Two messages collide on one channel.** That's the lane-steering path: the second message is injected into the running turn. The bot reacts ⏳ on the original message to acknowledge.