Skip to content

Discord

Discord capture has two independent halves: a live bot that polls chosen server channels going forward, and a backfill that imports Discord's own data export for your side of every conversation — including your direct messages, which no bot can read. Use either on its own, or both together.

Live bot (kind=discord)

What it does

A bot polls GET /channels/{id}/messages for each channel you tick in Settings › Connectors › Discord channels, paging forward from a per-channel Discord snowflake cursor (like the IMAP connector's UID cursor, but one cursor per channel). New messages are normalized the same way as everything else and written to cap_items; every author becomes a person in People.

Every 30 minutes the scheduler also runs capture_agent.discord_discover, which lists the servers the bot is a member of and their text and announcement channels into cap_discord_channels. That list is what the picker shows — the app itself never talks to Discord and never sees the bot token.

Turn on the Message Content intent first

Discord's privileged Message Content intent gates the content, attachments and embeds fields of every message a bot reads — over the REST API as well as the Gateway. Without it the poll still sees authors and timestamps but every body is blank, and the app shows empty bubbles. Enable it in the Developer Portal: your application → Bot → Privileged Gateway Intents → Message Content Intent → Save. A bot in a handful of servers can switch it on without a verification review. The channels themselves still only need the ordinary View Channel and Read Message History permissions. The connector refuses to file a page of blank messages: the row's last_error reads "Message Content intent is off for this bot …" and the cursor stays put until the intent is on.

Prereqs

  • A Discord bot application with a bot token, invited to the server(s) you want to capture, with View Channel + Read Message History on the channels you'll tick, and the Message Content Intent switched on (see the warning above).
  • Runs in the container scheduler — no host access needed.
  • DMs are out of scope for the bot: a bot cannot read a user's direct messages, and Jarvis never uses your own account token (a self-bot breaks Discord's terms and risks the account). Your side of your DMs comes from the backfill below.

Env / secret_ref

The bot token is a single secret key, TOKEN, under secret_ref DISCORD, giving the env var declared in module.yaml:

JARVIS_DISCORD_TOKEN=your-bot-token

Set it in .env on the box (/opt/jarvis/.env in the reference deploy) and restart the stack — compose.yaml forwards it into the scheduler, and a repo check (tools/checks/env_contract.py::unforwarded_manifest_secrets) fails CI if any module manifest secret is ever left out of that block again.

Turn it on

  1. Set JARVIS_DISCORD_TOKEN in .env; ./jarvis down then ./jarvis up (the reference deploy does this on every merge).
  2. Wait for the next discovery run — within 30 minutes Settings › Connectors › Discord channels lists the bot's servers. Until then it reads "No servers yet"; Refresh re-reads the list.
  3. Tick the channels to capture. Each tick writes the connector row at once (the controls grey out until the row reloads); the first tick creates the row, later ticks update it.
  4. Set Capture since — the date the first poll of each newly ticked channel starts from (default 2026-01-01). Channels already polled keep their place; moving the date never rewinds a channel.
  5. Paste Your Discord user id and Save, so your own messages count as sent (direction="out"). Copy it from Discord: Settings › Advanced › Developer Mode, then right-click your name → Copy User ID.

The scheduler's capture_agent.poll cron (every 10 minutes) picks the row up on its next run and drains each channel forward, 200 messages per run.

The cap_connectors row (what the picker writes)

{
  "kind": "discord",
  "enabled": true,
  "secret_ref": "DISCORD",
  "config": {
    "channels": ["channel-id-1", "channel-id-2"],
    "since": "2026-01-01"
  },
  "owner_addresses": ["your-discord-user-id"]
}

config.channels is an explicit allowlist across all servers — only the ids listed are polled. enabled is true exactly when the list is non-empty. config.since (YYYY-MM-DD) is where a channel with no cursor yet starts; a missing or unparsable value polls from the channel's first message. You can still create rows by hand in the PocketBase admin UI — older per-server rows keep polling; the picker edits the newest row and says how many others exist. To re-read a channel from an earlier date, delete its key from the row's cursor in the admin UI.

Verify

Settings › Connectors shows the Discord row as "N channels" with its last poll time. Then:

./jarvis logs scheduler

A missing token is not an error in the logs — look for discord discover: {'skipped': 'no token', …}; a healthy run prints created/updated/ deleted counts instead.

In the PocketBase admin, cap_items grows with new Discord messages, the row's last_poll_at updates each cron run, and last_error is empty.

Errors

  • Missing credentials — same as every connector: last_error becomes missing credentials (set the secret_ref env vars) and the row is skipped until the token is set and reachable. Discovery skips quietly.
  • Message Content intent off — last_error reads Message Content intent is off for this bot: channel … returned N messages with empty content …, nothing is filed and the cursor does not advance. Turn the intent on (warning above); the next poll resumes from the same cursor. If blank messages were filed before the guard existed, delete the empty Discord items and clear the row's cursor ({}) in the PocketBase admin; the next poll re-drains every ticked channel from the since date with content, and dedupe keeps re-runs safe.
  • Per-channel rate limiting or a transient error — that channel is skipped for this run with its cursor left unadvanced (other channels still poll normally) and retried on the next cron run.
  • A channel that answers 403 or 404 (deleted, or the bot lost access) is retried on the next two runs too. From its third failure in a row it is retried once a day, with one log line per retry: discord channel <id> unreachable (HTTP 404, Unknown Channel) … then … still unreachable …. Discord's reason tells a deleted channel (Unknown Channel) from a permission problem (Missing Access). The state sits in the row's cursor under _unreachable; the first successful poll clears it, and so does unticking the channel. Delete the key in the admin UI to retry at once.
  • A channel the bot can no longer see stays in your allowlist and is listed under "No longer visible to the bot" in the picker; untick it there.
  • Discovery errors never touch the snapshot: a failed server list writes nothing, a single failing server keeps its previous channels.

Backfill (data export) — your DMs and your own history

What it does

A one-shot import of Discord's own "Request all my Data" export package. The export contains every message you sent — in servers and in direct messages — so every imported item is tagged direction="out" and resolves to a single owner entity. There's no cap_connectors row involved; it's a CLI command you run once, then again with a later --since every few months to keep your DM side current.

Prereqs

  • Request your data export from Discord (User Settings → Privacy & Safety → Request all of my Data), wait for the mail, and unzip the package on the box.
  • Optionally, your Discord user id, if the export package doesn't include account/user.json (some trimmed exports omit it).

Run it

python -m capture_agent.backfill discord --package /path/to/discord-export --since 2026-01-01

If the package has no account/user.json, pass your id explicitly:

python -m capture_agent.backfill discord --package /path/to/discord-export --since 2026-01-01 --owner your-discord-user-id

--since filters to messages on or after that date (YYYY-MM-DD).

Re-import cadence

Request a fresh export every few months and re-run the command with --since set to your previous import's date. Re-runs are safe: cap_items dedupes by external_id (the Discord message id), so messages already imported — and messages the live bot already captured from a ticked channel — are left alone; only new ones land.

Verify

The command prints a summary when it finishes, e.g. capture discord backfill: {'ingested': N, 'skipped_dupe': 0, 'unrouted': N, 'entities_created': 1, 'errors': 0}. Check cap_items in the PocketBase admin for the new rows.

Errors

Per-message parsing failures are logged and skipped so one bad row never aborts the whole import.