Skip to content

Exact one-to-one private-message lifecycle

Private messaging is an explicit exact-user exception to the connector's guild boundary, not an extension of guild read scope and not a shared messaging service. Each operator runs the connector with their own pinned Discord application, bot identity, and secret bot token. The connector never supplies a communal bot, asks a third party to relay private content, or lets one installation inherit another installation's recipient authority. Use Discord's Create DM endpoint only for ordinary users who reasonably expect contact; Discord warns that opening many private channels can cause rate limiting or blocking. Exact local scope is necessary connector authorization, but it is not proof of consent, prior contact, Discord-side eligibility, or successful delivery.

Apply the additive direct-messenger recipe to an existing complete schema-v2 configuration, or set the same fields manually. The relevant fragment below is not a complete standalone document; keep the existing credential, pinned identity, storage, runtime, limits, Gateway, read scope, and any other selected toolsets unchanged.

{
"capabilities": {
"directMessageAudit": true,
"directMessageDelivery": true,
"directMessageEditing": true,
"directMessageDeletion": true
},
"scopes": {
"directMessageUserIds": ["EXPECTED_RECIPIENT_USER_ID"]
},
"tools": {
"surface": "full",
"toolsets": ["connector", "direct-messages"]
}
}

The recipe enables four independent lifecycle gates but intentionally does not enable local-file disclosure. To add private files manually, also set capabilities.directMessageAttachments: true and configure at least one canonical owned directory in storage.attachmentRoots; the existing limits.attachmentMaxBytes applies and cannot exceed 10 MiB. This fifth gate requires ordinary direct-message delivery, but remains independent of guild capabilities.attachments and scopes.attachmentChannelIds. scopes.directMessageUserIds accepts only a bounded set of exact ordinary user IDs and remains independent of readScope.guildIds, readScope.channelIds, member scope, interaction scope, and every guild permission. The direct-messages toolset exposes no recipient search, username resolution, private-channel inventory, group DM, bulk-recipient action, or DM Gateway subscription. Discord's Message Content intent exception covers messages in DMs with the app, so this workflow adds no privileged intent or guild permission.

list_direct_messages and get_direct_message require both the allowlisted recipient ID and a caller-known exact one-to-one channel ID; exact lookup additionally requires the message ID. Every call re-verifies the pinned application and bot, channel type, sole ordinary recipient, both message participants, and exact message boundary. Bounded results classify each message as text, static-components-v2, single-attachment, or unsupported-rich. Supported static layouts are normalized to Text Display, Separator, callback-free Link Row, and Container nodes with a deterministic preview; Discord-generated component IDs are discarded. A link destination is returned transiently as part of that reviewed static layout and remains untrusted. A supported single attachment exposes only its exact ID, safe filename, nullable bounded description, and numeric size. Results return supported evidence transiently along with exact message IDs, timestamps, flags, pin state, supported message type, reply target, author side, and aggregate rich-state and mention counts. They omit custom-ID component actions, profile names, avatars, attachment and proxy URLs, MIME types, dimensions, placeholders, CDN signatures, raw attachment and Discord objects, group conversations, and unknown values, and they never cache, journal, index, download, or persist the private body or link destination.

The reviewed mutation surface accepts four tagged actions. send takes the exact recipient and an explicit expected-contact acknowledgement, while planning reads only that user and an explicitly requested local file when present; it never opens or discovers a channel. Approved send execution may call Create DM to open or reuse the one-to-one channel and returns its exact channel ID. reply additionally requires a caller-known exact channel and reply target plus the same contact acknowledgement. send and reply carry a strict message union: { "kind": "text", "content": "..." }, { "kind": "components-v2", "components": [...] }, or { "kind": "attachment", "filePath": "/absolute/path", "filename": "optional-safe-name", "description": "optional description", "content": "optional text" }. Attachment requests use the shared canonical owned-file boundary with a private-message-specific keyed digest domain and accept exactly one nonempty regular single-link file under a configured root. They never accept remote or data URLs, base64 or MCP byte fields, multiple files, streams, directories, symlinks, hard links, caller-selected MIME types, or files above the configured ceiling. edit remains restricted to text and Components V2. The Components V2 branch accepts only bounded static Text Display, Separator, callback-free Link Row, and Container nodes. Link buttons use the same exact scopes.componentLinkOrigins policy as guild publication, and a disallowed origin fails before private-channel or recipient access. Request rows are rejected because the native broker is exact-guild scoped. Other custom-ID buttons, interactive styles, selects, modals, media, callbacks, caller-selected component IDs, arbitrary embed URLs, and mixed legacy content are unavailable. Discord's IS_COMPONENTS_V2 flag is irreversible for a message, so edit requires the existing message and requested body to have the same format and never converts between text and Components V2.

edit and delete require an exact channel and exact connector-authored message. Editing admits only an unpinned same-format text or Components V2 message without attachments, embeds, stickers, reactions, or parsed mention state. It cannot replace, retain, remove, or convert a file. Deletion additionally admits one unpinned single-attachment projection with no parsed mention, embed, component, sticker, reaction, poll, snapshot, call, TTS, or unsupported flag state, and requires an irreversible-deletion acknowledgement. Every action requires a bounded local reviewReason and one unique one-shot operationKey; body and file review data are transient and required only where the action carries them. The review reason is never sent as a Discord audit-log header.

Plain-text send request:

{
"acknowledgeExpectedRecipientContact": true,
"action": "send",
"message": {
"kind": "text",
"content": "Hello from the reviewed connector"
},
"operationKey": "one-unique-private-message-operation-key",
"recipientId": "EXPECTED_RECIPIENT_USER_ID",
"reviewReason": "Recipient requested this exact follow-up"
}

Static Components V2 send request after adding https://status.example.com to scopes.componentLinkOrigins:

{
"acknowledgeExpectedRecipientContact": true,
"action": "send",
"message": {
"kind": "components-v2",
"components": [
{
"kind": "container",
"accentColor": 1193046,
"spoiler": false,
"components": [
{
"kind": "text",
"content": "## Reviewed update\nStatic layout, no callbacks"
},
{
"kind": "separator",
"divider": true,
"spacing": "large"
},
{
"kind": "link-row",
"buttons": [
{
"label": "Read the reviewed update",
"url": "https://status.example.com/incidents/example"
}
]
}
]
}
]
},
"operationKey": "one-unique-static-private-message-operation-key",
"recipientId": "EXPECTED_RECIPIENT_USER_ID",
"reviewReason": "Recipient requested this exact structured update"
}

Owned-file send request after enabling the separate private-attachment gate and root:

{
"acknowledgeExpectedRecipientContact": true,
"action": "send",
"message": {
"kind": "attachment",
"filePath": "/absolute/configured/attachment-root/report.pdf",
"filename": "report.pdf",
"description": "Requested report",
"content": "The requested report is attached"
},
"operationKey": "one-unique-private-file-operation-key",
"recipientId": "EXPECTED_RECIPIENT_USER_ID",
"reviewReason": "Recipient requested this exact report"
}
  1. Call plan_direct_message_change with the complete action-specific request.
  2. Review the pinned identities, exact recipient and existing channel or message when applicable, current and desired state, canonical file path, safe metadata, size, ownership, containment, link, and stable-read evidence when present, contact or deletion acknowledgement, empty mention policy, fixed rate limits, privacy omissions, risks, operation-key hash, and process-bound keyed digest.
  3. Call execute_direct_message_change with identical intent plus that digest, then approve the signed MCP confirmation only if every displayed field remains intended.
  4. After completion, restart, or uncertainty, call verify_direct_message_change with the exact caller-retained request. Never retry the spent key or substitute a new key merely because the outcome is unclear.

All outbound messages force the minimal empty allowed-mention set and disable reply-author notification. The connector admits at most five private-message changes per rolling minute and at most one change per recipient every five seconds; these fixed ceilings cannot be raised through configuration. It does not sleep through a local limit and never automatically retries Create DM, create-message, multipart upload, edit, or delete requests. A send uses a deterministic enforced nonce after its exact channel is known. An attachment uses one native multipart request with one JSON descriptor and one file part, no guild ID, and no remote source. Every mutation requires exact presentation and body or receipt-bound attachment metadata plus a fresh exact message readback, or exact 404 absence for deletion.

Execution re-creates the plan, including a fresh stable byte snapshot when present, durably coordinates the exact user and any known channel and message targets, reserves a strict request-bound schema-v2 content-free receipt, and appends pending activity before contacting Discord. Send checkpoints the returned channel before dispatch and every action checkpoints the exact message identity before readback. Receipt stages are immutable and monotonic, so a restart cannot reinterpret an earlier operation or replace its targets. A known pre-write Discord 4xx may settle as failed. Rate limiting, transport failure, Discord 5xx, malformed or mismatched evidence, failed readback, or a recording failure after possible contact is uncertain and quarantines the operation. Verification first authenticates the caller-retained request against the token-derived receipt binding, then reads only the receipt-bound exact message or absence when the receipt is terminal and suitable for readback; it never reopens the local file, downloads the remote attachment, scans history, or trusts caller-selected recovery targets. Discord exposes no attachment content digest, so recovery compares the caller-retained filename, description, and optional content with receipt-bound byte size and live URL-free metadata rather than claiming remote byte equality. Rotating the bot token deliberately invalidates prior request verification.

Durable receipts and activity records contain only exact recipient, channel, reply-target, and message IDs where applicable, the action and attachment, text, or components-v2 format class for body-carrying actions, nullable attachment byte size in the receipt, request and plan digests, operation-key hash, immutable stage, timestamps, fixed status and verification values, activity ID, and a sanitized error category. They never contain a local path, file bytes, filename, description, file metadata, byte digest, private message text, component layouts, link destinations or origins, generated component IDs, previews, attachment URLs, embeds, usernames, profile names, avatars, review reasons, raw operation keys, nonce, raw Discord objects, routes, response bodies, or transport causes. Use list_activity for content-free lifecycle evidence and keep the original exact request privately under caller control when later verification may be needed.

Canonical source: docs/reference.md

Documentation generated for guildcontrol@0.0.0. Canonical source and edit history remain in the public repository. GuildControl is an independent project and is not affiliated with or endorsed by Discord Inc. Discord is used only to identify the platform that GuildControl connects to.