# discord.md — Discord Behavior & Routing

## Access Control
- `DISCORD_ALLOW_USER_IDS` is the primary gate.
- Fail closed: if the allowlist is empty, DiscoClaw responds to nobody.
- Optional: `DISCORD_CHANNEL_IDS` restricts the bot to specific guild channels (DMs are still allowed).

## Base PA Context

Base PA behavioral and safety context is loaded from `.context/pa.md` and `.context/pa-safety.md`
in the repo root. These are required — the bot refuses to start if either is missing.

## Channel Context (Token-Efficient)
DiscoClaw inlines per-channel context files into the runtime prompt.

Layout (under `$DISCOCLAW_CONTENT_DIR` or `$DISCOCLAW_DATA_DIR/content`):
- `discord/DISCORD.md` (index: channel -> id -> context file)
- `discord/channels/*.md` (per-channel context modules)
- `discord/channels/_default.md` (fallback for unknown channels)
- `discord/channels/dm.md` (DM fallback)

Behavior:
- For each message, DiscoClaw tells the runtime to `Read` the relevant channel context file before responding.
- For threads, the parent channel context applies.

Strict mode:
- `DISCORD_REQUIRE_CHANNEL_CONTEXT=1` (default): the bot requires a per-channel context file.
- `DISCORD_AUTO_INDEX_CHANNEL_CONTEXT=1` (default): when a message arrives for a new channel, DiscoClaw appends it to `discord/DISCORD.md` and creates a blank stub file.

Thread auto-join:
- `DISCORD_AUTO_JOIN_THREADS=1` (default): best-effort auto-join threads so the bot can respond inside them. Private threads still require adding the bot manually.

## Conversation History & Memory
When `DISCOCLAW_MESSAGE_HISTORY_BUDGET` > 0 (default: `3000`), DiscoClaw fetches recent messages from the Discord channel and includes them in the prompt as conversation context. This allows Claude to maintain conversational context across messages without relying on CLI session persistence. The char budget caps the total history size; messages are selected newest-first so the most relevant context is preserved. Bot responses are truncated when necessary to fit the budget.

### Rolling Summaries
When `DISCOCLAW_SUMMARY_ENABLED=1` (default), DiscoClaw maintains a rolling conversation summary per session key, generated by Haiku every N turns (`DISCOCLAW_SUMMARY_EVERY_N_TURNS`, default 5). The summary is a compressed narrative of earlier conversation — decisions, preferences, current focus — kept under `DISCOCLAW_SUMMARY_MAX_CHARS` characters.

Summaries are stored on disk at `data/memory/rolling/<safe-session-key>.json` and injected into the prompt between context files and recent conversation as a "Conversation memory" section. When recent messages and the summary conflict, the recent messages take precedence (the summary is lossy). Summary generation is best-effort: failures are logged and do not block responding.

### Durable Memory
When `DISCOCLAW_DURABLE_MEMORY_ENABLED=1` (default), DiscoClaw maintains persistent per-user memory that survives across channels and sessions. Items are stored at `data/memory/durable/<user-id>.json` and include facts, preferences, projects, and other user-specific notes.

Active durable items are injected into the prompt between context files and conversation memory, sorted by most recently updated, capped by `DISCOCLAW_DURABLE_INJECT_MAX_CHARS` (default 2000). Each user can store up to `DISCOCLAW_DURABLE_MAX_ITEMS` (default 200) items.

When `DISCOCLAW_MEMORY_COMMANDS_ENABLED=1` (default), users can manage their durable memory via commands:
- `!memory` or `!memory show` — display active durable items and rolling summary
- `!memory remember <text>` — store a new fact
- `!memory forget <text>` — deprecate matching items (requires 60% text-length match)
- `!memory reset rolling` — clear the rolling summary for the current session

Memory commands are intercepted before runtime invocation and do not consume API tokens.

## Session Keys
- DM: `discord:dm:<authorId>`
- Thread: `discord:thread:<threadId>` (if the incoming channel is a thread)
- Channel: `discord:channel:<channelId>`

These session keys map to persisted UUIDs via `data/sessions.json`.

## Concurrency (Single Flight)
- DiscoClaw serializes processing per session key to avoid interleaving tool runs/context.
- Implementation: `src/group-queue.ts`

## Output Constraints
- Discord has a ~2000 char limit per message.
- DiscoClaw chunks long replies and attempts to keep fenced code blocks renderable across splits.

## Discord Actions
When `DISCOCLAW_DISCORD_ACTIONS=1` (master switch), Claude can perform Discord server actions by emitting `<discord-action>` blocks in its response. After the runtime completes, DiscoClaw parses these blocks, executes them via discord.js, strips the blocks from the displayed message, and appends results.

Each action category has its own flag (only active when the master switch is `1`):

| Flag | Default | Actions |
|------|---------|---------|
| `DISCOCLAW_DISCORD_ACTIONS_CHANNELS` | `1` | channelCreate, channelEdit, channelDelete, channelList, channelInfo, categoryCreate, channelMove, threadListArchived, forumTagCreate, forumTagDelete, forumTagList, threadEdit |
| `DISCOCLAW_DISCORD_ACTIONS_MESSAGING` | `1` | sendMessage, sendFile, react, unreact, readMessages, fetchMessage, editMessage, deleteMessage, bulkDelete, crosspost, threadCreate, pinMessage, unpinMessage, listPins, reactionPrompt |
| `DISCOCLAW_DISCORD_ACTIONS_GUILD` | `1` | memberInfo, roleInfo, roleAdd, roleRemove, searchMessages, eventList, eventCreate, eventEdit, eventDelete |
| `DISCOCLAW_DISCORD_ACTIONS_MODERATION` | `0` | timeout, kick, ban |
| `DISCOCLAW_DISCORD_ACTIONS_POLLS` | `1` | poll |
| `DISCOCLAW_DISCORD_ACTIONS_TASKS` | `1` | taskCreate, taskUpdate, taskClose, taskShow, taskList, taskSync, tagMapReload |
| `DISCOCLAW_DISCORD_ACTIONS_CRONS` | `1` | cronCreate, cronUpdate, cronList, cronShow, cronPause, cronResume, cronDelete, cronTrigger, cronSync, cronTagMapReload |
| `DISCOCLAW_DISCORD_ACTIONS_BOT_PROFILE` | `1` | botSetStatus, botSetActivity, botSetNickname |
| `DISCOCLAW_DISCORD_ACTIONS_FORGE` | `1` | forgeCreate, forgeResume, forgeStatus, forgeCancel |
| `DISCOCLAW_DISCORD_ACTIONS_PLAN` | `1` | planList, planShow, planApprove, planClose, planCreate, planRun |
| `DISCOCLAW_DISCORD_ACTIONS_MEMORY` | `1` | memoryRemember, memoryForget, memoryShow |
| `DISCOCLAW_DISCORD_ACTIONS_DEFER` | `1` | defer |
| `DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN` | `0` | generateImage |
| `DISCOCLAW_DISCORD_ACTIONS_VOICE` | `0` | voiceStatus, voiceJoin, voiceLeave, voiceSetVoice |
| `DISCOCLAW_DISCORD_ACTIONS_SPAWN` | `1` | spawnAgent |
| _(config — always on)_ | — | modelSet, modelShow |

Notes:
- `reactionPrompt` is gated by the MESSAGING flag — it is registered via `REACTION_PROMPT_ACTION_TYPES` only when `flags.messaging` is true (`src/discord/actions.ts:113`).
- Config actions (`modelSet`, `modelShow`) have no separate env flag. They are always enabled when the master switch is on, hardcoded in `src/index.ts`.
- `generateImage` supports two providers: **OpenAI** (models: `dall-e-3`, `gpt-image-1`) and **Gemini** (models: `imagen-4.0-generate-001`, `imagen-4.0-fast-generate-001`, `imagen-4.0-ultra-generate-001`). Provider is auto-detected from the model prefix (`dall-e-*`/`gpt-image-*` → openai, `imagen-*` → gemini) or set explicitly via the `provider` field. OpenAI provider uses `OPENAI_API_KEY` (required) and optional `OPENAI_BASE_URL`. Gemini provider uses `IMAGEGEN_GEMINI_API_KEY`. At least one key must be set when `DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1`. Default model is auto-detected: if only `IMAGEGEN_GEMINI_API_KEY` is set, defaults to `imagen-4.0-generate-001`; otherwise defaults to `dall-e-3`. Override with `IMAGEGEN_DEFAULT_MODEL`.
- `spawnAgent` is enabled by default (`DISCOCLAW_DISCORD_ACTIONS_SPAWN=1`; set to 0 to disable). Spawned agents run fire-and-forget: each agent runs its prompt via the configured runtime and posts its output directly to the target channel. Multiple `spawnAgent` actions in a single response run in parallel (bounded by `DISCOCLAW_DISCORD_ACTIONS_SPAWN_MAX_CONCURRENT`, default 8). Spawn is disabled for bot-originated messages and excluded from cron flows to prevent recursive agent chains. Spawned agents run at recursion depth 1 and cannot themselves spawn further agents.

Action guard (false completion detection): When a reply's visible text claims Discord-managed work was performed or is being performed — in any tense (present progressive, future intent, past tense, or perfect tense) — but the turn produced zero actionable `<discord-action>` blocks and zero executed action results, the finalizer appends a visible warning. This catches fabricated completion claims ("Posted the plan", "I've sent the message") at the output boundary so the user sees the discrepancy immediately. The guard is implemented in `output-common.ts` (`claimsImmediateDiscordActionIntent` + `appendPromisedDiscordActionWithoutExecutionNotice`) and requires no prompt-layer changes — it operates purely on the finalized reply text and action execution counts.

Auto-follow-up: When query actions (channelList, channelInfo, threadListArchived, forumTagList, readMessages, fetchMessage, listPins, memberInfo, roleInfo, searchMessages, eventList, taskList, taskShow, cronList, cronShow, planList, planShow, memoryShow, modelShow, forgeStatus) succeed, DiscoClaw automatically re-invokes Claude with the results. This allows Claude to reason about query results without requiring the user to send a follow-up message. Controlled by `DISCOCLAW_ACTION_FOLLOWUP_DEPTH` (default `3`, `0` disables). Mutation-only responses do not trigger follow-ups. Trivially short follow-up responses (<50 chars with no actions) are suppressed.

Requirements:
- The bot needs appropriate permissions in the server (Manage Channels, Manage Roles, Moderate Members, etc.) depending on the actions used. These are server-level role permissions, not Developer Portal settings.
- Only works in guild channels (not DMs).
- Master switch defaults to on (`1`). Only allowlisted users can trigger actions.
- Destructive actions (delete, kick, ban, timeout) prompt Claude to confirm with the user first.
- If actions fail with "Missing Permissions", the bot's role lacks the required permission.

## Status Channel
When `DISCOCLAW_STATUS_CHANNEL` is set to a channel name or ID, DiscoClaw posts plain-text status messages on key events:
- **Bot Online** — posted after the `ready` event fires
- **Bot Offline** — posted on SIGTERM/SIGINT (best-effort)
- **Runtime Error** — runtime invocation failed or timed out
- **Handler Failure** — uncaught exception in message processing
- **Action Failed** — a Discord action returned `{ ok: false }`

Fail-open: if the channel is not found or the env var is unset, the bot works normally with no status posts. Errors posting to the status channel are caught and logged, never crashing the bot.

Implementation: `src/discord/status-channel.ts`

## Cron (Scheduled Tasks)
When `DISCOCLAW_CRON_ENABLED=1` (default), `DISCOCLAW_CRON_FORUM` must be set to the "automations" forum channel ID (snowflake). DiscoClaw runs a forum-based cron subsystem. Each forum thread is a cron job: the thread name is the job name, and the starter message is a natural-language definition that gets parsed by AI into a schedule, timezone, target channel, and prompt.

Creating a cron: create a thread in the forum. The starter message should describe the schedule, target channel, and what to do (e.g., "Every weekday at 7am Pacific, check the weather for Portland OR and post a brief summary to #general."). The bot reacts with a checkmark and replies with the parsed schedule.

Managing crons:
- **Disable:** Archive the thread. The bot stops scheduling.
- **Enable:** Unarchive the thread. The bot re-parses and resumes.
- **Edit:** Edit the starter message. The bot re-parses on the next `messageUpdate`.
- **Delete:** Delete the thread. The bot removes the job.

Archive vs delete: archiving a thread is reversible (sets the archived flag; thread still exists and can be unarchived). Deleting a thread is permanent (thread and its messages are gone). The `cronDelete` action archives the thread — it does not delete it — so history is preserved and the cron can be restored by unarchiving.

Cron responses are posted to the target channel only (threads stay clean as config). Discord actions are supported in cron responses if the master switch is enabled. Failures are posted to the status channel.

Overlap protection: if a previous run for the same job is still active, the next tick is skipped.

Implementation: `src/cron/`

## Tasks (Task Tracking)
DiscoClaw includes a task tracker backed by in-process `TaskStore` data and synced to Discord forum threads.

- Data model/store: `src/tasks/types.ts`, `src/tasks/store.ts`
- Discord task action path: `src/tasks/task-action-executor.ts` (dispatch), `src/tasks/task-action-mutations.ts` (create/update/close), `src/tasks/task-action-thread-sync.ts` (thread lifecycle helpers), `src/tasks/task-action-mutation-helpers.ts` (shared mutation helpers), `src/tasks/task-action-read-ops.ts` (show/list/sync/reload), `src/tasks/task-action-contract.ts` (request types)
- Canonical sync modules: `src/tasks/task-sync-engine.ts`, `src/tasks/task-sync-pipeline.ts` (facade), `src/tasks/task-sync-apply-plan.ts`, `src/tasks/task-sync-reconcile-plan.ts`, `src/tasks/task-sync-apply-types.ts`, `src/tasks/task-sync-phase-apply.ts`, `src/tasks/task-sync-reconcile.ts`, `src/tasks/sync-coordinator.ts`, `src/tasks/sync-coordinator-metrics.ts`, `src/tasks/sync-coordinator-retries.ts`, `src/tasks/thread-helpers.ts`, `src/tasks/thread-forum-ops.ts`, `src/tasks/thread-lifecycle-ops.ts`, `src/tasks/thread-ops.ts` (facade), `src/tasks/tag-map.ts`

Auto-sync is event-driven from the in-process store and runs a startup reconciliation pass.
Auto-triggered syncs are silent; explicit `taskSync` can post status output.

Primary env surface is `DISCOCLAW_TASKS_*`.

## Discord API Quirks

**Thread name + archive in one call:** When updating a thread's name AND archiving it in a single PATCH request, the name updates but the archive flag doesn't stick. Do them as separate API calls with a short delay between.

## Group CWD Mode
If `USE_GROUP_DIR_CWD=1`:
- CWD becomes `groups/<sessionKey>/` for that Discord context.
- The main workspace (`WORKSPACE_CWD`) is added via `--add-dir` so tools can still read/write it.
- DiscoClaw bootstraps `groups/<sessionKey>/CLAUDE.md` on first use.

## Known Footguns

- **Thread name + archive in one API call:** Updating a thread's name AND archiving it in a single Discord API PATCH silently drops the archive flag. These must be separate API calls with a short delay between. This bites task sync and cron archive operations.
- **Bot permissions are role-based, not OAuth scope-based:** "Missing Permissions" errors come from the bot's server role lacking a permission (e.g., Manage Channels), not from the Developer Portal OAuth settings. Fix it in Server Settings → Roles.
- **DMs bypass `DISCORD_CHANNEL_IDS`:** The channel restriction only applies to guild channels. DMs are always allowed if the user is in the allowlist. This is by design but can surprise operators who expect full channel lockdown.
- **`DISCORD_REQUIRE_CHANNEL_CONTEXT=1` silently drops messages:** If no context file exists for a channel and auto-indexing hasn't run yet, the bot silently ignores messages in that channel. No error is logged. Run `pnpm sync:discord-context` to pre-create stubs.
- **Auto-follow-up depth can cause API cost spikes:** With `DISCOCLAW_ACTION_FOLLOWUP_DEPTH=3` (default), a single user message can trigger up to 4 runtime invocations (initial + 3 follow-ups). Each costs API tokens. Set to `1` if cost is a concern.
- **Reaction handler age gate:** Reactions on messages older than `DISCOCLAW_REACTION_MAX_AGE_HOURS` (default 24) are silently ignored. If users react to old messages expecting a response, increase this value or disable the age gate.

## Common Failure Modes

### Bot responds in some channels but not others
**Symptom:** Bot works in DMs and some guild channels but silently ignores messages in other channels.
**Cause (in order of likelihood):**
1. `DISCORD_CHANNEL_IDS` is set and the channel is not in the list.
2. `DISCORD_REQUIRE_CHANNEL_CONTEXT=1` and no context file exists for that channel.
3. The channel is a private thread the bot hasn't been added to.
**Recovery:**
```bash
# Check channel restrictions
grep DISCORD_CHANNEL_IDS .env

# Check if context files exist for the channel
ls data/content/discord/channels/

# Regenerate context stubs for all channels
pnpm sync:discord-context

# For private threads: manually add the bot to the thread in Discord
```

### Discord action fails with "Missing Permissions"
**Symptom:** Bot replies with "Action failed: Missing Permissions" when trying to create/edit channels, roles, etc.
**Cause:** The bot's server role lacks the required Discord permission.
**Recovery:**
```bash
# Common permission requirements by action type:
# channelCreate/channelEdit/channelDelete → Manage Channels
# roleAdd/roleRemove → Manage Roles
# timeout/kick/ban → Moderate Members / Kick Members / Ban Members
# pinMessage/unpinMessage → Manage Messages
# bulkDelete → Manage Messages
# crosspost → Manage Messages (in announcement channels)

# Fix: Server Settings → Roles → [Bot Role] → enable the needed permission
# The bot role must also be ABOVE the target role in the role hierarchy for role operations
```

### Action follow-ups produce empty or truncated responses
**Symptom:** Bot invokes a query action (e.g., `channelList`), gets results, but the follow-up response is empty or cut short.
**Cause:** Follow-up response was <50 chars and got suppressed by the trivial-response filter, or the follow-up depth limit was reached.
**Recovery:**
```bash
# Check the follow-up depth setting
grep DISCOCLAW_ACTION_FOLLOWUP_DEPTH .env

# Increase if needed (but be mindful of API cost)
# DISCOCLAW_ACTION_FOLLOWUP_DEPTH=5

# If the trivial-response filter is the issue, the bot correctly determined
# no further response was needed. This is usually correct behavior.
```

### Status channel messages not appearing
**Symptom:** `DISCOCLAW_STATUS_CHANNEL` is set but no status messages appear.
**Cause:** Channel name/ID doesn't match any channel the bot can see, or the bot lacks Send Messages permission in that channel.
**Recovery:**
```bash
# Verify the channel ID or name
grep DISCOCLAW_STATUS_CHANNEL .env

# Use a channel ID (snowflake) instead of a name for reliability
# Names are matched case-sensitively and can break on rename

# Check bot logs for status channel errors (these are logged but non-fatal)
journalctl --user -u discoclaw.service --since "5 min ago" --no-pager | grep -i "status"
```

### Bot claims it performed a Discord action but nothing happened
**Symptom:** Bot says "Posted the plan to #general" or "I've sent the message" but no message appears in the target channel.
**Cause:** The model fabricated a completion claim without emitting a `<discord-action>` block. The action guard should append a visible warning to the reply.
**Verification:**
1. Check whether the reply ends with a warning starting with `Warning: this reply says Discord-managed work was performed`.
2. If the warning is present, the guard is working — the model hallucinated the action. No code fix needed; the warning tells the user.
3. If no warning is present but the action still didn't execute, check whether the action block was parsed but failed during execution (look for "Action Failed" in the status channel or bot logs).
```bash
# Check recent action failures
journalctl --user -u discoclaw.service --since "5 min ago" --no-pager | grep -i "action.*fail\|warning.*discord-action"
```

### Messages split awkwardly across Discord's 2000-char limit
**Symptom:** Bot replies are split mid-sentence or mid-code-block, producing garbled formatting.
**Cause:** The chunking algorithm tries to preserve code fences but can't always split cleanly if the response has deeply nested or very long code blocks.
**Recovery:**
```bash
# This is a known limitation of Discord's message size limit.
# No env var to adjust — the chunking is best-effort.
# Workaround: ask the bot to produce shorter responses, or
# use thread replies where each chunk is a separate message.
```

### Task sync fails — threads out of sync with local store
**Symptom:** Task forum threads show stale data (wrong status, missing tasks, or duplicate threads).
**Cause:** A previous sync failed mid-operation, or the bot was offline when task changes occurred.
**Recovery:**
```bash
# Trigger a manual sync via Discord:
# Ask the bot: "sync tasks"

# Check sync coordinator status in logs
journalctl --user -u discoclaw.service --since "10 min ago" --no-pager | grep -i "sync\|coordinator"

# If the thread cache is stale, restart the bot (cache is cleared on startup)
systemctl --user restart discoclaw.service
```
