Skip to main content
Glama
discord-tools

discord-tools

by craft-agent
README.md
# discord-tools

Discord tools for coding agents: chats, messages, search, inbox, people and servers, history, files, sends, threads and roles.

> [!WARNING]
> These tools act from your own Discord account, through the web client's
> API and with your account's token. Discord's terms forbid automating a user
> account ("self-bots"), and an account can be terminated for it, read-only
> use included. Use them only on an account whose loss you accept, and only
> for what you would do yourself, at your own pace. The token is your account:
> whoever holds it reads and writes as you, past your password and your second
> factor.

| Harness        | Delivery                                       | Pinned by                      |
| -------------- | ---------------------------------------------- | ------------------------------ |
| OpenCode V2    | native plugin (npm package, with skills)       | the version in the package     |
| OpenCode V1    | native plugin (npm package, no skills)         | the version in the package     |
| Claude Code    | plugin: MCP server and skills                  | the release tag, as the ref    |
| Codex          | plugin: MCP server and skills                  | the release tag, as the ref    |
| Grok Build     | plugin: MCP server and skills                  | the release tag, as `@ref`     |
| Antigravity    | plugin: MCP server and skills                  | the release tag's tree URL     |
| Hermes Agent   | native plugin (Python, calls the command line) | the release commit, as the SHA |
| other MCP host | MCP server from npm                            | the version in the package     |

The tools need [Node.js](https://nodejs.org/) 24 or later.
Plugin installs pinned by a release tag run on macOS (arm64, x64) and Linux
(x64, arm64, glibc); npm installs run wherever wreq-js has a binding.
[`docs/conventions.md`](docs/conventions.md) describes how the tools are named
and how they behave.

## Install

Install from a release, never from the default branch: a release is a tag whose
commit carries the built `dist/`, and the default branch does not. In the
commands below, `<version>` is a release's version (`1.2.3`) and `<sha>` is the
commit its tag names:

```sh
gh api repos/craft-agent/discord-tools/commits/v<version> --jq .sha
```

### OpenCode

OpenCode V2 takes the package and its options in `opencode.json`:

```json
{
  "plugins": [{ "package": "@craftsamo/discord-tools@<version>", "options": { "read_only": false } }],
  "permissions": [{ "action": "discord_*", "resource": "*", "effect": "ask" }]
}
```

OpenCode V1 takes the package as a string, or as a pair with its options:

```json
{
  "plugin": [["@craftsamo/discord-tools@<version>", { "read_only": false }]],
  "permission": { "discord_*": "ask" }
}
```

OpenCode allows any action no rule names, so the plugin's request is granted
silently unless a rule says `ask`. Read tools never ask, so the `discord_*` glob
is safe.

V1 registers no skills. Unpack the package and copy its `skills/*` folders into
`~/.config/opencode/skills/`:

```sh
npm pack @craftsamo/discord-tools@<version>
tar -xzf craftsamo-discord-tools-<version>.tgz
mkdir -p ~/.config/opencode/skills
cp -r package/skills/* ~/.config/opencode/skills/
rm -rf package craftsamo-discord-tools-<version>.tgz
```

### Claude Code

```sh
claude plugin marketplace add craft-agent/discord-tools#v<version>
claude plugin install discord-tools@craft-agent
```

### Codex

```sh
codex plugin marketplace add craft-agent/discord-tools --ref v<version>
codex plugin add discord-tools@craft-agent
```

Codex turns `-` into `_` in server names, so it shows the tools as
`mcp__discord_tools__discord_*`. It starts the MCP server in the plugin's
install directory and passes no roots, so the server takes each call's directory
from what Codex sends with the call: its sandbox state, which the server asks
for, else the one workspace it names.

The plugin declares `default_tools_approval_mode = "writes"`: Codex asks before
every tool not marked read-only. A user's
`[plugins."<plugin>".mcp_servers.<server>]` setting can only tighten it. Setting
`auto` there yields `prompt` (reads asked too), since Codex treats `auto` and
`writes` as incomparable.

### Grok Build

```sh
grok plugin install craft-agent/discord-tools@v<version> --trust
```

Grok starts the MCP server in the session's working directory.

### Antigravity

Install from the tree of the release tag, never from the default branch, which
has no built `dist/`; Antigravity clones the tag into
`~/.gemini/config/plugins/discord-tools`.

```sh
agy plugin install https://github.com/craft-agent/discord-tools/tree/v<version>
```

### Hermes Agent

Hermes installs a commit, not a tag, and the plugin installs disabled:

```sh
hermes plugins install craft-agent/discord-tools --ref <sha>
hermes plugins enable discord-tools
```

If a Hermes gateway is already running, restart it (`hermes gateway restart`) to
load the plugin.

The plugin runs the Node it found when Hermes started. If a version manager
removes that version while Hermes runs, a call looks for Node again; when it
finds none, restart Hermes from a shell where `node` runs.

### Any other MCP host

Add the server to the host's MCP configuration. Flags go in `args`:

```json
{
  "mcpServers": {
    "discord-tools": {
      "command": "npx",
      "args": ["-y", "@craftsamo/discord-tools@<version>", "--read-only"]
    }
  }
}
```

## Tools and skills

Each toolset comes with a skill that tells an agent how to use its tools. A
skill names its toolset under `metadata` in its frontmatter
(`metadata: { toolset: sends }`, written as a block). OpenCode V2 and Hermes
register it only when the settings register at least one of that toolset's
tools, so a profile with the toolset off, or with all its tools removed, does
not see it; a skill without a toolset is always registered. Claude Code,
Codex, Grok Build and Antigravity install the skill folder whole and pass the
settings only to the MCP server, so they list every skill whatever the
settings select. OpenCode V1 registers no skills, and its copied skills are
all listed too.

| Toolset | Tool                      | Skill                                              | Does                                                    |
| ------- | ------------------------- | -------------------------------------------------- | ------------------------------------------------------- |
| account | `discord_account_read`    | [discord-account](skills/discord-account/SKILL.md) | reads local account, token and sync health              |
| sync    | `discord_sync_read`       | [discord-sync](skills/discord-sync/SKILL.md)       | shows the sync list and suggests changes                |
| sync    | `discord_sync_add`        | [discord-sync](skills/discord-sync/SKILL.md)       | follows a server or named channels                      |
| sync    | `discord_sync_remove`     | [discord-sync](skills/discord-sync/SKILL.md)       | stops following a server or channels                    |
| chats   | `discord_chat_read`       | [discord-chats](skills/discord-chats/SKILL.md)     | reads servers, channels, DMs and messages               |
| search  | `discord_search_read`     | [discord-search](skills/discord-search/SKILL.md)   | searches the mirror or Discord's search                 |
| inbox   | `discord_inbox_read`      | [discord-inbox](skills/discord-inbox/SKILL.md)     | lists mentions, waiting chats and saves                 |
| inbox   | `discord_saved_add`       | [discord-inbox](skills/discord-inbox/SKILL.md)     | saves a message, optionally with a reminder             |
| inbox   | `discord_saved_remove`    | [discord-inbox](skills/discord-inbox/SKILL.md)     | removes a save and its reminder                         |
| inbox   | `discord_read_mark`       | [discord-inbox](skills/discord-inbox/SKILL.md)     | marks one chat read, on every device                    |
| people  | `discord_people_read`     | [discord-people](skills/discord-people/SKILL.md)   | reads friends, notes, profiles and members              |
| people  | `discord_server_read`     | [discord-people](skills/discord-people/SKILL.md)   | reads a server's info, roles, emoji, events and invites |
| sends   | `discord_message_send`    | [discord-sends](skills/discord-sends/SKILL.md)     | sends a text, optionally as a reply                     |
| sends   | `discord_file_send`       | [discord-sends](skills/discord-sends/SKILL.md)     | sends files, optionally with a text                     |
| history | `discord_history_read`    | [discord-history](skills/discord-history/SKILL.md) | counts messages per chat, author or day                 |
| history | `discord_export_create`   | [discord-history](skills/discord-history/SKILL.md) | writes a chat's history to a private file               |
| files   | `discord_file_save`       | [discord-files](skills/discord-files/SKILL.md)     | saves the files of a message to disk                    |
| actions | `discord_message_edit`    | [discord-actions](skills/discord-actions/SKILL.md) | edits the account's own message                         |
| actions | `discord_message_delete`  | [discord-actions](skills/discord-actions/SKILL.md) | deletes the account's own message                       |
| actions | `discord_reaction_add`    | [discord-actions](skills/discord-actions/SKILL.md) | adds the account's reaction                             |
| actions | `discord_reaction_remove` | [discord-actions](skills/discord-actions/SKILL.md) | takes back the account's reaction                       |
| actions | `discord_pin_add`         | [discord-actions](skills/discord-actions/SKILL.md) | pins a message                                          |
| actions | `discord_pin_remove`      | [discord-actions](skills/discord-actions/SKILL.md) | unpins a message                                        |
| threads | `discord_thread_create`   | [discord-threads](skills/discord-threads/SKILL.md) | starts a thread in a channel or from a message          |
| threads | `discord_post_create`     | [discord-threads](skills/discord-threads/SKILL.md) | posts in a forum or media channel                       |
| threads | `discord_file_post`       | [discord-threads](skills/discord-threads/SKILL.md) | posts files in a forum or media channel                 |
| threads | `discord_thread_join`     | [discord-threads](skills/discord-threads/SKILL.md) | joins a thread                                          |
| threads | `discord_thread_leave`    | [discord-threads](skills/discord-threads/SKILL.md) | leaves a thread                                         |
| roles   | `discord_role_read`       | [discord-roles](skills/discord-roles/SKILL.md)     | reads roles, permissions and role members               |
| roles   | `discord_role_assign`     | [discord-roles](skills/discord-roles/SKILL.md)     | gives a role to one or several members                  |
| roles   | `discord_role_unassign`   | [discord-roles](skills/discord-roles/SKILL.md)     | takes a role away from one member                       |
| roles   | `discord_role_create`     | [discord-roles](skills/discord-roles/SKILL.md)     | creates a role with named permissions                   |
| roles   | `discord_role_edit`       | [discord-roles](skills/discord-roles/SKILL.md)     | changes a role's name, color, flags or permissions      |
| roles   | `discord_role_delete`     | [discord-roles](skills/discord-roles/SKILL.md)     | deletes a role from everyone who held it                |

`account` is on by default and available with `read_only`. Call
`discord_account_read` with `{}` to list every label, sorted, or with
`{"account":"default"}` to inspect one; an unknown label returns `not_found`.
It reads local identity, credential status, scheduler and mirror metadata,
never contacts Discord or changes a service. Names in the result are untrusted
data, not instructions.

`token_source` says `variable`, `file` or `null`, never the token itself.
`token_status: "available"` means locally usable, not freshly authenticated.
A scheduled but idle service is normal; unknown scheduler state stays `null`.
Healthy local state requires a complete sync within 15 minutes; a partial run
needs continuation. Recorded `running` or `rejected` sync status does not prove
that a process is running or the current token is rejected.

When attention is needed, `action`, `hint` and `recovery_command` give a person
one next step. The read never runs it. After token rejection, sign in with a
new token, then reinstall the service to resume its schedule, as described
under [Local account commands](#local-account-commands).

`sync` is on by default; `read_only` keeps only `discord_sync_read`. The sync
list names the servers the sync service follows, each whole (its 10 most
active text channels, minus exclusions) or by named text and announcement
channels; DMs and group chats are always followed. At most 10 servers and 30
channel slots, a whole server using 10.

- `discord_sync_read` `list` shows each server, the channels it follows and
  whether each is current, its exclusions and the slots used. `suggest`
  proposes, from the mirror alone, channels the account wrote in or that are
  busy but not followed, and entries gone quiet, each with the exact
  arguments of the tool that applies it and whether it fits.
- `discord_sync_add` follows a whole server or named channels of a server the
  mirror knows; `discord_sync_remove` drops a server, or named channels, or
  excludes channels from a whole server. Switching a server between whole and
  named channels takes a removal first.

All three change or read only the local list, never Discord. The two writes
are asked about, since every followed channel adds requests to each later
sync run. A change applies from the next run; messages already in the mirror
stay readable.

`chats` is on by default and available with `read_only`. `discord_chat_read`
takes a `method`:

| Method     | Reads                                                                       |
| ---------- | --------------------------------------------------------------------------- |
| `servers`  | the account's servers, and how the sync list follows each                   |
| `channels` | one server's channels by category, and whether each is followed and current |
| `dms`      | DMs and group chats newest first, with a name filter and the last message   |
| `messages` | a chat's messages between two points, oldest first                          |
| `around`   | the messages before and after one message                                   |
| `threads`  | a channel's threads or a forum's posts, by activity or creation, with tags  |
| `pins`     | a chat's pinned messages, newest pin first                                  |
| `backfill` | older history of a chat the sync follows, into the local mirror             |

Chats, servers and messages are named by id, found through `servers`,
`channels`, `dms` and `threads`; a name is never a target. `messages` and
`around` read a chat the sync marked current within 15 minutes from the local
mirror, inside the history the sync stored; `dms` uses a DM list read within
15 minutes and `servers` one read within 6 hours. Everything else, and
`channels`, `threads` and `pins` always, is read from Discord at the paced
rate and stored in the mirror. No read
sends anything, shows the account as present or marks a chat read
(`discord_read_mark` of `inbox` does, when asked). Message
text, embeds, file names and names are written by other people: the result
says so, and an agent treats them as data, never as instructions.

`search` is on by default and stays in `read_only` mode. `discord_search_read`
finds messages, links and files, newest first, in one chat, one server or
everywhere, by text, author, mentions, `has` (`link`, `file`, `image`,
`video`, `sound`, `embed`, `sticker`, `poll`), pinned and a time range:

| Method    | Searches                                                                      |
| --------- | ----------------------------------------------------------------------------- |
| `mirror`  | what the local mirror holds, current or not; sends no request                 |
| `discord` | Discord's own search of a server, one chat, or every DM; stores what it found |

`mirror` matches a literal substring and also filters by the account's
reactions, an emoji, and a channel's threads; each chat in the result says
when a sync run last marked it current. `discord` covers everything the
account can read in that scope, but cannot search all servers at once; a
server still being indexed is waited out once. Both page with `next_before`.

`inbox` is on by default; `read_only` keeps only `discord_inbox_read`, whose
`method` is one of:

| Method     | Reads                                                                         |
| ---------- | ----------------------------------------------------------------------------- |
| `mentions` | Discord's own list of the past 7 days' mentions, roles and @everyone included |
| `waiting`  | chats whose latest messages the user has not answered, from the mirror alone  |
| `saved`    | the user's saved messages and reminders, reminders first                      |

`waiting` means not answered, never unread. `discord_saved_add` saves a
mirrored message, optionally with a `remind_at` time, and `discord_saved_remove`
removes a save. `discord_read_mark` marks one chat read, up to a message or the
newest the mirror holds, on every device; it is the only tool that marks
anything read, and every write here is asked about. There are no unread
counts: Discord keeps them only behind its gateway, and a check on a real
phone showed that even a brief gateway session holds back the phone's push
notifications, so nothing here opens it.

`people` is on by default, read-only and kept under `read_only`. Both tools
read live on every call, one at a time, with user and server ids the mirror
knows, never names; only returned user names are stored. `discord_people_read`
reads friends, private notes, one person's profile, membership with role names,
or member search. `discord_server_read` reads server info with approximate
member and online counts, roles, custom emoji and stickers, upcoming events
and interested users, or invites. Member search and invites need Manage Server.
Invite codes go to the user only, never into a message, file or another tool
unless the user asks for that; notes are private and never shared unless asked.

`sends` is on by default; `read_only` leaves it out. `discord_message_send`
sends one text to a DM, group DM, server channel or thread the mirror knows,
by id, and never opens a new DM. The text arrives exactly as given. `reply_to`
answers a mirrored message of that chat without pinging its author;
`@everyone`, `@here` and role mentions notify only with `mass_mentions`, and
`silent` sends without notifications. Every send is asked about.

`discord_file_send` sends 1 to 10 files, optionally with a text, the same way.
The files must lie inside the folders `send_dirs` allows (by default the
working directory, never a home directory), each at most `send_max_mb`.
Credentials, keys and databases, programs and archives that cannot be
inspected are refused, and one refused file refuses the whole send. What is
checked is a private copy, and the copy is what is uploaded; a file changed
after the prompt makes the call `stale`. Files go only to Discord's storage,
without the token, and nothing is posted unless every file is up.

A send goes out once and is never retried. Its nonce is recorded in the
mirror's send ledger before it leaves, with a hash of the text, never the
text. The result is the message Discord returned, stored in the mirror. A
refusal is its error. Anything else is `uncertain`: the message may have been
sent, and `details.matches` lists the account's messages with the same text
from one read-back of the chat. Check the chat before sending again.

`actions` is off by default, because it changes what others already read; turn
it on with `toolsets` (for example `--toolsets all`, or the defaults plus
`actions`). `read_only` leaves it out. Every call names a chat and a message by
id, and the message must be in the mirror. Every action is asked about.

- `discord_message_edit` replaces the text of the account's own message, sent
  exactly as given; `discord_message_delete` removes the account's own
  message, which cannot be undone, and counts a message Discord no longer has
  as deleted (`already`).
- `discord_reaction_add` and `discord_reaction_remove` add or take back the
  account's own reaction on any message: one Unicode emoji, or a custom emoji
  already on the message; never a super reaction.
- `discord_pin_add` and `discord_pin_remove` pin or unpin any message; Discord
  decides whether the account may, and caps a chat at 250 pins.

Before a request, the tool refuses what it can know is wrong: a name for an
id, a message the mirror does not hold, someone else's message to edit or
delete, an unusable emoji, an edit that changes nothing, a system message to
pin, a reaction to take back that is not the account's. Each action is sent
once and never retried, and followed in the mirror. Anything that may have
reached Discord is `uncertain`: `details.applied` says whether one read-back of
the chat saw the new state (`null` when it could not tell). Repeating an
action is harmless, but check the chat and ask first.

`threads` is off by default because it creates lasting posts; turn it on with
`toolsets` (for example `--toolsets all`, or the defaults plus `threads`).
`read_only` leaves all five tools out, and every call is asked about. Targets
are ids the mirror knows: `discord_thread_create` starts a public or private
thread in a text or announcement channel, optionally from a message (public
only); `discord_post_create` creates a text post in a forum or media channel;
`discord_file_post` creates one with files, optionally with text;
`discord_thread_join` and `discord_thread_leave` change only the user's
membership. Tags are ids from `discord_chat_read` method `threads`; files
follow a file send's directory, size and safety rules. Nothing is retried.
Creation has no nonce: for `uncertain`, read `details.read_back`, check the
channel's threads or posts and ask before trying again. Never repeat a
creation blindly; a repeated join is harmless, but check before leaving again.

`roles` is off by default because it changes what others may do; turn it on
with `toolsets` (for example `--toolsets all`, or the defaults plus `roles`).
`read_only` keeps only `discord_role_read`; every write is asked about each
time, even under an allow rule. Read the roles first with method `list` or
`role`: writes need a snapshot no older than 15 minutes, and a change to the
user's own roles needs a fresh read. Targets are server, role and user ids the
mirror knows, never names. Roles can be given, taken, created, edited or
deleted; `administrator` is never given, created or granted, nor is anything
the user lacks. The audit log names the user and records optional `reason`.
Nothing is retried: for `uncertain`, read `details.read_back`, read the roles
again and ask the person. Creation's candidates are only candidates; never
repeat a creation blindly. Giving again or taking again is harmless, but
check first.

`history` is off by default because the export writes files; turn it on with
`toolsets` (for example `--toolsets all`, or the defaults plus `history`).
`read_only` keeps only `discord_history_read`, whose method `counts` counts
the mirror's ordinary messages and replies per chat, author or day, over the
past 30 days by default, without reading text. Totals cover the whole window;
`limit` cuts only rows. Days use `time_zone`, by default the machine's.
`coverage.partial` and `coverage.not_current` flag lower bounds, not Discord's
totals: backfill older gaps in a followed chat or wait for the next sync.

`discord_export_create` writes one chat's history held in one piece by the
mirror, current or not, as Markdown, JSON or JSONL, with whole texts and message
permalinks. Files go under `<download_dir>/exports/`, private (0600 files in a
0700 folder), never overwritten; taken names get a number. `count`, `current`,
`left_out`, `truncated` and `complete_to_start` say what the file covers.
Continue into a new file with `continue.older.before` or `continue.newer.after`;
`continue.backfill_first` means backfill a followed chat before going older.
Every export is asked about. Neither tool makes a request to Discord. Exports
may hold private DMs; never send, upload or share one unless the person asks.

`files` is off by default, because it writes files to disk; turn it on with
`toolsets` (for example `--toolsets all`, or the defaults plus `files`).
`read_only` leaves it out. `discord_file_save` takes a chat and a message by
id, the message being one the mirror holds, and saves its attachments, the
images of its link previews and its stickers to
`<download_dir>/<channel>-<message>/`. Every save is asked about.

Nothing is overwritten: the same name with the same bytes is reported
`already`, any other gets `name-2.ext`. Files come only from Discord's own
CDN and media proxy, without the token, never from the site behind a link. Each file is
checked by its name, its declared type and its bytes before it is kept:
programs, installers, scripts and archives that cannot be read are `refused`,
and a file over `download_max_mb` is `too_large`. The others go on, each with
its outcome. Files carry no execute bit and, on macOS, are marked as
downloaded.

An archive (`.zip`, `.tar`, `.tar.gz`, `.tgz`) is saved packed, with a listing
of what it holds. With `unpack: true`, and only when asked, it is also
unpacked into a new folder beside it, all of it or only the `entries` named
from the listing. Where files go and their size limit are the `download_dir`
and `download_max_mb` [settings](#settings).

## Local account commands

These are commands for a person, not agent tools. They print plain text;
`list`, `run` and `target` keep their JSON output.

```sh
discord-tools-cli login --account default
discord-tools-cli sync --account default
discord-tools-cli service install --account default
discord-tools-cli service status --account default
discord-tools-cli service uninstall --account default
discord-tools-cli remove --account default
```

`login` verifies the account with Discord. When
`DISCORD_TOOLS_TOKEN_DEFAULT` is supplied in its environment, it uses that
variable and stores no token. Otherwise, it reads a token from standard input:
one hidden line at a terminal, or a UTF-8 pipe through EOF. A secret manager
can pipe its output directly into `discord-tools-cli login --account default`.
The verified token is then stored in the label's private token file (mode 600).
Never pass a token as a command-line argument.

Each accepts `--state-dir <directory>`; otherwise `DISCORD_TOOLS_STATE_DIR`
or the default state directory is used. Status distinguishes a scheduled
job from a run currently in progress. Status and uninstall accept an explicit
label even after its local state has been removed.

`sync` runs one bounded pass and prints status and counts. A complete run or
an already-busy label exits 0; a partial run exits 1 and needs another run.
Authentication rejection pauses the label's schedule and keeps its service
definition. To resume, run `login` with a new token, then `service install`
for that label.

`service install` requires a signed-in label and schedules the installed CLI
every five minutes: a launchd agent on macOS, or a systemd user timer and
oneshot service on Linux. Keep that CLI installation and its Node runtime
available at their installed paths, and run `service install` again after
updating either. The service definition records the
runtime, command, state directory and `PATH`, never a token. Where Node's path
holds its version, as under a version manager, `service install` records a
`node` on `PATH` at a path without it that starts the same Node, and warns when
there is none; that `node` then runs whichever Node the manager makes current.
The label's `service.json` keeps the runtime and CLI paths; `service status`
and `discord_account_read` say when either is gone, once the service has been
installed with this version. A service manager
does not automatically inherit the terminal's environment: when using
`DISCORD_TOOLS_TOKEN_<LABEL>`, supply it independently to the scheduled
service's environment, as well as to the harness that starts the MCP server.

On macOS, Login Items lists the service as `discord-tools-sync-<label>`: the
agent starts through that launcher script, which `service install` writes to
`~/Library/Application Support/io.github.craft-agent.discord-tools/` and
`service uninstall` deletes. Switching the item off there stops syncing.

`remove` asks you to type the label. For a non-interactive command, pass
`--yes`. It uninstalls the scheduled job, refuses while the label is busy,
and deletes that label's local state, including its mirror and token file.
If a foreground sync is still running, the state is kept and the schedule
stays stopped; stop that run before trying removal again.
It neither deletes anything on Discord nor removes credentials supplied by
the environment. Other labels are kept.

## Adding a tool

1. Write the spec in `src/core/<domain>/<verb>.ts` with `defineTool`: an ID
   `<prefix>_<domain>_<verb>`, a title and description, the Zod argument and
   output schemas, its toolset and write class, and `execute(args, context)`.
   A write tool also gives `targets(args, context)`: what the call writes to,
   as a permission rule should name it. A tool whose settings can put some
   calls out of reach whatever the person answers gives
   `gate(args, context)`, which throws for such a call; it is refused before
   anyone is asked, and before `execute`.
2. Test it beside the spec (`<verb>.test.ts`), calling it through `invoke` as
   the entry points do. A tool that runs a program is tested against a fake of
   that program on `PATH`; no test reaches a real service.
3. List it in `src/tools.ts`. A new toolset goes into `toolsets` in
   `src/project.ts` (and `defaultToolsets` when it is on by default), a
   setting of the package's own into `options` (and `OPTIONS` in `project.py`
   with `config_schema` in `plugin.yaml`), and a variable the tool reads
   besides the settings into `toolVariables`.
4. Add or update the toolset's skill, `skills/<prefix>-<toolset>/SKILL.md`,
   whose frontmatter names the toolset under `metadata`.
5. Run `pnpm run build && pnpm run manifests` to list the tool in
   `plugin.yaml` and its variables in Codex's manifest, then describe it under
   "Tools and skills".

[`docs/conventions.md`](docs/conventions.md) has the rules each step follows.

A package declares each option as `text`, `boolean`, `number`, `list`, `path`
or `paths`, or as an object with `type` and optional bounds. For example, in
`src/project.ts`:

```ts
options: {
  notes_dir: "path",
  note_limit: { type: "number", integer: true, min: 1, max: 50 },
}
```

`integer: true` requires a whole number; `min` and `max` bound a number or
count a `list` or `paths` value's items. An object with only `type` means the
same as the type alone. Hermes's `OPTIONS` mirrors the declarations on one
line, with double quotes and Python's `True` for `integer`:

```python
OPTIONS: dict[str, str | dict[str, object]] = {"notes_dir": "path", "note_limit": {"type": "number", "integer": True, "min": 1, "max": 50}}
```

## Reads and writes

Every read tool is named `<prefix>_<domain>_read`. Reads can run unasked;
writes are confirmed by the harness.

| Harness     | Reads without asking                                                                                           |
| ----------- | -------------------------------------------------------------------------------------------------------------- |
| OpenCode    | always; the plugin asks only before a write                                                                    |
| Claude Code | the rule `mcp__plugin_discord-tools_discord-tools__*_read` in `permissions.allow` of `~/.claude/settings.json` |
| Codex       | always; Codex runs tools that declare `readOnlyHint` unasked, as the read tools do                             |
| Grok Build  | `grok --allow 'MCPTool(discord-tools__*_read)' --allow 'MCPTool(discord_*_read)'` (both rules; see below)      |
| Antigravity | the rule `mcp(discord-tools_discord-tools/<tool id>)` in `permissions.allow`, one for each read tool           |
| Hermes      | always; the plugin asks only before a write                                                                    |

Where a write is asked about, and where it is not:

- **OpenCode** grants any action no rule names, so a write runs unasked unless
  a rule asks for it: add the `discord_*` rule shown under [OpenCode](#opencode).
- **Codex** asks before every write because of the plugin's approval mode.
  Under `codex exec` it refuses with "MCP tool call requires approval, but
  approval policy is never".
- **Grok Build** under `grok -p` refuses a write it cannot ask about, unless it
  is allowed beforehand.
- **Grok Build** calls the plugin's tools through its `use_tool` tool, and the
  agent often names a tool without its server first. Grok checks the rules
  against the name as given, so the server-qualified rule alone does not match
  that first call. The second rule matches the short name; a write still
  matches neither.
- **Antigravity** asks before any MCP tool no rule allows, reads included, and
  names the plugin's server `discord-tools_discord-tools` (plugin,
  then server). `agy -p` refuses a tool it cannot ask about.
- **Hermes** `hermes chat -q` refuses a write it cannot ask about; `hermes -z`
  and `--yolo` skip approval, so writes then run unasked.

A call a tool's `gate` refuses never runs, whatever the answer. OpenCode and
Hermes refuse it before they ask, so no prompt shows it; Hermes blocks it with
the tool's error. Claude Code, Codex, Grok Build and Antigravity draw their
own prompt before the MCP server sees the call, so they may still ask about
it; the server then refuses it.

## Settings

Every entry point takes the same settings. A flag wins over its environment
variable, and an option or setting wins over the variable too.

| Setting           | Flag                | Variable                        | Meaning                                                                                                                     |
| ----------------- | ------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `toolsets`        | `--toolsets`        | `DISCORD_TOOLS_TOOLSETS`        | the toolsets that are on, or `all`; default those `src/project.ts` names                                                    |
| `read_only`       | `--read-only`       | `DISCORD_TOOLS_READ_ONLY`       | registers no tool that writes; wins over every other setting                                                                |
| `tools`           | `--tools`           | `DISCORD_TOOLS_TOOLS`           | single tools to add by ID, whatever their toolset                                                                           |
| `exclude_tools`   | `--exclude-tools`   | `DISCORD_TOOLS_EXCLUDE_TOOLS`   | single tools to remove by ID; wins over `tools` and `toolsets`                                                              |
| `confirm`         | `--confirm`         | `DISCORD_TOOLS_CONFIRM`         | MCP server only: `harness` (default) or `elicitation`, as the conventions say                                               |
| `language`        | `--language`        | `DISCORD_TOOLS_LANGUAGE`        | native plugins only: the approval prompt's language, such as `ja`                                                           |
| `emoji`           | —                   | —                               | Hermes only: the emoji shown beside each toolset, by toolset name (`""` for none)                                           |
| `state_dir`       | `--state-dir`       | `DISCORD_TOOLS_STATE_DIR`       | `path`: where every label's state lives; default `$XDG_STATE_HOME/discord-tools`                                            |
| `download_dir`    | `--download-dir`    | `DISCORD_TOOLS_DOWNLOAD_DIR`    | `path`: where received files are saved; exports go under its private `exports/` folder; default `~/Downloads/discord-tools` |
| `download_max_mb` | `--download-max-mb` | `DISCORD_TOOLS_DOWNLOAD_MAX_MB` | the most megabytes a received file may have, a whole number from 1 to 500; default 100                                      |
| `send_dirs`       | `--send-dirs`       | `DISCORD_TOOLS_SEND_DIRS`       | `paths`: where files may be sent from, at most 20; default the working directory                                            |
| `send_max_mb`     | `--send-max-mb`     | `DISCORD_TOOLS_SEND_MAX_MB`     | the most megabytes a sent file may have, a whole number from 1 to 500; default 10                                           |

Lists are comma-separated in a flag or a variable, `send_dirs` too. This
package's own settings, from `state_dir` to `send_max_mb`, are listed in
`options` in `src/project.ts` and read the same way. A `path` is trimmed; `~`
and `~/…` (also with the platform separator) stand for the home directory
(`HOME` when it is absolute, else the account's). It must then be absolute and
reaches the tool normalized, without a filesystem check. `paths` is a list of
such paths, comma-separated in a flag or variable and an array of strings in a
plugin option. A blank path counts as unset, so the default applies; blank
list items are dropped, and a list with none left counts as unset. Bounds check only a given value, not an unset option. Refused values are
`invalid_argument`, naming the flag, variable or option and what it must be.
Hermes passes paths and lists to the command line, which checks the paths and
bounds. Where each harness reads them:

- **OpenCode** takes the plugin `options` shown above, with lists as arrays,
  `read_only` as a boolean and the megabyte limits as numbers, and falls back
  to the variables. V2 runs plugins in its background service, which keeps
  the environment it started with: a
  variable set where `opencode run` starts has no effect until the service is
  restarted in that environment (`opencode service restart`). V1 runs plugins
  in its own process.
- **Hermes** takes `plugins.entries.discord-tools.settings` in
  `config.yaml`:

  ```yaml
  plugins:
    entries:
      discord-tools:
        settings:
          read_only: false
          exclude_tools: []
  ```

  Without a `language` setting, the approval card follows Hermes's own
  `display.language` (or `HERMES_LANGUAGE`), the language of its approval
  buttons, and else stays in English. The plugin passes its own environment
  to the command line it runs, so the variables work too. A running gateway
  keeps the environment it started with;
  restart it (`hermes gateway restart`) after changing one.

- **Claude Code, Grok Build and Antigravity** start the plugin's server without
  flags and pass it the client's environment, so set the variables in the
  environment the harness runs in.
- **Codex** passes a plugin's MCP server only a few basic variables (such as
  `PATH` and `HOME`) and those the plugin's manifest names, which are the
  variables above: set them in the environment Codex runs in. A variable the
  tools read besides the settings belongs in `toolVariables` in
  `src/project.ts`; `pnpm run manifests` adds it to the manifest after the
  settings' variables. To pass flags, or to give the server a variable the
  package does not name, add the server under `[mcp_servers]` in Codex's
  `config.toml` instead of using the plugin: flags go in `args`, `env_vars`
  names the variables to forward from Codex's environment, and `env` sets
  fixed values.

  ```toml
  [mcp_servers.discord-tools]
  command = "npx"
  args = ["-y", "@craftsamo/discord-tools@<version>", "--read-only"]
  env_vars = ["DISCORD_TOOLS_TOOLSETS"]
  ```

### Accounts, the token and the sync service

- **Each Discord account has a label**, 1 to 24 lowercase letters, digits and
  `_` (`default` when signing in names none). A tool's `account` argument
  picks the label; leave it out when only one account is signed in.
- **Each label keeps its state in one directory**, `<state_dir>/<label>/`
  (mode 700): the mirror of synced messages, the sync list, the token file
  when there is one, and the copies of files being sent or received.
  `state_dir` defaults to `$XDG_STATE_HOME/discord-tools` when
  `XDG_STATE_HOME` is absolute, else `~/.local/state/discord-tools`.
  The mirror holds your private messages, so
  keep the directory out of backups you share.
- **The token comes from `DISCORD_TOOLS_TOKEN_<LABEL>` when it is set**
  (`DISCORD_TOOLS_TOKEN_DEFAULT` for `default`), and from the token file that
  signing in writes otherwise. With the variable, nothing of the token is
  stored by the package: inject it from the secret manager you use, into the
  environment of both the harness (its MCP server reads live and writes) and
  the sync service. For Codex, list it in `env_vars` as above.
- **A sync service keeps each label's mirror**, polling Discord every 5
  minutes in short, capped runs: DMs and group DMs always, servers only as the
  sync list names them (at most 10 servers and 30 channels; the `sync` tools
  change it). A read uses the
  mirror only while its channel was synced in the last 15 minutes, and reads
  Discord directly otherwise. The service sends nothing of its own: no
  presence, no typing, no read marks.
- **A rejected token stops the service** for its label until a new one is
  given; changing your Discord password invalidates the token.

## Layout

- `src/project.ts` — the package's name, tool ID prefix, setting prefix,
  toolsets, tool options and the other variables its tools read.
- `src/core/` — the tool contract and the tools, free of any harness and of
  the project's names.
- `src/tools.ts` — the list of every tool, which each entry point registers.
- `src/words.ts` — the translations of the tools' words, per language, which
  the native plugins use in their approval prompts.
- `src/mcp/` — the MCP server, built to `dist/mcp.js` and run by
  `discord-tools`.
- `src/opencode/` — the OpenCode plugin, built to `dist/opencode.js`.
- `src/cli/` — the command line the Hermes plugin calls, built to `dist/cli.js`
  and run by `discord-tools-cli`.
- `src/hermes/`, `plugin.yaml` and `__init__.py` — the Hermes plugin.
- `skills/` — one skill per toolset, `skills/<name>/SKILL.md`, which every
  harness that takes skills loads from the package or plugin root; the native
  plugins load only those of the toolsets that are on.
- `.claude-plugin/`, `.codex-plugin/`, `.agents/plugins/`, `.mcp.json`,
  `.antigravity-plugin/` and `mcp_config.json` — the plugin manifests of the
  other harnesses. Codex reads `.codex-plugin/plugin.json`; a root
  `plugin.json` would make it load the plugin as an Agent Plugin, whose tool
  budget hides tools past a fixed size. The root `.mcp.json` is the
  configuration Grok Build reads (with `${CLAUDE_PLUGIN_ROOT}`, the variable
  both Grok Build and Claude Code expand); when working on this repository in
  Claude Code, decline the project MCP server it offers from that file.

How a release is made is in [`RELEASING.md`](RELEASING.md).

## License

[AGPL-3.0 with Commons Clause](LICENSE)